Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node
This commit is contained in:
commit
bd08b341f0
|
|
@ -30,26 +30,68 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
|
||||||
routingTable = "item_routing_version",
|
routingTable = "item_routing_version",
|
||||||
routingFkColumn = "item_code",
|
routingFkColumn = "item_code",
|
||||||
search = "",
|
search = "",
|
||||||
|
extraColumns = "",
|
||||||
|
filterConditions = "",
|
||||||
} = req.query as Record<string, string>;
|
} = req.query as Record<string, string>;
|
||||||
|
|
||||||
const searchCondition = search
|
|
||||||
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
|
|
||||||
: "";
|
|
||||||
const params: any[] = [companyCode];
|
const params: any[] = [companyCode];
|
||||||
if (search) params.push(`%${search}%`);
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
// 검색 조건
|
||||||
|
let searchCondition = "";
|
||||||
|
if (search) {
|
||||||
|
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추가 컬럼 SELECT
|
||||||
|
const extraColumnNames: string[] = extraColumns
|
||||||
|
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||||
|
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||||
|
|
||||||
|
// 사전 필터 조건
|
||||||
|
let filterWhere = "";
|
||||||
|
if (filterConditions) {
|
||||||
|
try {
|
||||||
|
const filters = JSON.parse(filterConditions) as Array<{
|
||||||
|
column: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
for (const f of filters) {
|
||||||
|
if (!f.column || !f.value) continue;
|
||||||
|
if (f.operator === "equals") {
|
||||||
|
filterWhere += ` AND i.${f.column} = $${paramIndex}`;
|
||||||
|
params.push(f.value);
|
||||||
|
} else if (f.operator === "contains") {
|
||||||
|
filterWhere += ` AND i.${f.column} ILIKE $${paramIndex}`;
|
||||||
|
params.push(`%${f.value}%`);
|
||||||
|
} else if (f.operator === "not_equals") {
|
||||||
|
filterWhere += ` AND i.${f.column} != $${paramIndex}`;
|
||||||
|
params.push(f.value);
|
||||||
|
}
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
} catch { /* 파싱 실패 시 무시 */ }
|
||||||
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
i.id,
|
i.id,
|
||||||
i.${nameColumn} AS item_name,
|
i.${nameColumn} AS item_name,
|
||||||
i.${codeColumn} AS item_code,
|
i.${codeColumn} AS item_code
|
||||||
|
${extraSelect ? ", " + extraSelect : ""},
|
||||||
COUNT(rv.id) AS routing_count
|
COUNT(rv.id) AS routing_count
|
||||||
FROM ${tableName} i
|
FROM ${tableName} i
|
||||||
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||||
AND rv.company_code = i.company_code
|
AND rv.company_code = i.company_code
|
||||||
WHERE i.company_code = $1
|
WHERE i.company_code = $1
|
||||||
${searchCondition}
|
${searchCondition}
|
||||||
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
|
${filterWhere}
|
||||||
|
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}, i.created_date
|
||||||
ORDER BY i.created_date DESC NULLS LAST
|
ORDER BY i.created_date DESC NULLS LAST
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -711,3 +753,184 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 등록 품목 관리 (item_routing_registered)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면별 등록된 품목 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getRegisteredItems(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { screenCode } = req.params;
|
||||||
|
const {
|
||||||
|
tableName = "item_info",
|
||||||
|
nameColumn = "item_name",
|
||||||
|
codeColumn = "item_number",
|
||||||
|
routingTable = "item_routing_version",
|
||||||
|
routingFkColumn = "item_code",
|
||||||
|
search = "",
|
||||||
|
extraColumns = "",
|
||||||
|
} = req.query as Record<string, string>;
|
||||||
|
|
||||||
|
const params: any[] = [companyCode, screenCode];
|
||||||
|
let paramIndex = 3;
|
||||||
|
|
||||||
|
let searchCondition = "";
|
||||||
|
if (search) {
|
||||||
|
searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraColumnNames: string[] = extraColumns
|
||||||
|
? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||||
|
const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", ");
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
irr.id AS registered_id,
|
||||||
|
irr.sort_order,
|
||||||
|
i.id,
|
||||||
|
i.${nameColumn} AS item_name,
|
||||||
|
i.${codeColumn} AS item_code
|
||||||
|
${extraSelect ? ", " + extraSelect : ""},
|
||||||
|
COUNT(rv.id) AS routing_count
|
||||||
|
FROM item_routing_registered irr
|
||||||
|
JOIN ${tableName} i ON irr.item_id = i.id
|
||||||
|
AND i.company_code = irr.company_code
|
||||||
|
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||||
|
AND rv.company_code = i.company_code
|
||||||
|
WHERE irr.company_code = $1
|
||||||
|
AND irr.screen_code = $2
|
||||||
|
${searchCondition}
|
||||||
|
GROUP BY irr.id, irr.sort_order, i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}
|
||||||
|
ORDER BY CAST(irr.sort_order AS int) ASC, irr.created_date ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 registerItem(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { screenCode, itemId, itemCode } = req.body;
|
||||||
|
if (!screenCode || !itemId) {
|
||||||
|
return res.status(400).json({ success: false, message: "screenCode, itemId 필수" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
const result = await getPool().query(query, [
|
||||||
|
screenCode, itemId, itemCode || null, companyCode, req.user?.userId || null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.json({ success: true, message: "이미 등록된 품목입니다", data: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("품목 등록", { companyCode, screenCode, itemId });
|
||||||
|
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 registerItemsBatch(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { screenCode, items } = req.body;
|
||||||
|
if (!screenCode || !Array.isArray(items) || items.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: "screenCode, items[] 필수" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const inserted: any[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const result = await client.query(
|
||||||
|
`INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (screen_code, item_id, company_code) DO NOTHING
|
||||||
|
RETURNING *`,
|
||||||
|
[screenCode, item.itemId, item.itemCode || null, companyCode, req.user?.userId || null]
|
||||||
|
);
|
||||||
|
if (result.rows[0]) inserted.push(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("품목 일괄 등록", { companyCode, screenCode, count: inserted.length });
|
||||||
|
return res.json({ success: true, data: inserted });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("품목 일괄 등록 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록 품목 제거
|
||||||
|
*/
|
||||||
|
export async function unregisterItem(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 item_routing_registered 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
|
||||||
// 전체 저장 (일괄)
|
// 전체 저장 (일괄)
|
||||||
router.put("/save-all", ctrl.saveAll);
|
router.put("/save-all", ctrl.saveAll);
|
||||||
|
|
||||||
|
// 등록 품목 관리 (화면별 품목 목록)
|
||||||
|
router.get("/registered-items/:screenCode", ctrl.getRegisteredItems);
|
||||||
|
router.post("/registered-items", ctrl.registerItem);
|
||||||
|
router.post("/registered-items/batch", ctrl.registerItemsBatch);
|
||||||
|
router.delete("/registered-items/:id", ctrl.unregisterItem);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,620 @@
|
||||||
|
# POP 작업진행 관리 설계서
|
||||||
|
|
||||||
|
> 작성일: 2026-03-13
|
||||||
|
> 목적: POP 시스템에서 작업지시 기반으로 라우팅/작업기준정보를 조회하고, 공정별 작업 진행 상태를 관리하는 구조 설계
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 핵심 설계 원칙
|
||||||
|
|
||||||
|
**작업지시에 라우팅ID, 작업기준정보ID 등을 별도 컬럼으로 넣지 않는다.**
|
||||||
|
|
||||||
|
- 작업지시(`work_instruction`)에는 `item_id`(품목 ID)만 있으면 충분
|
||||||
|
- 품목 → 라우팅 → 작업기준정보는 마스터 데이터 체인으로 조회
|
||||||
|
- 작업 진행 상태만 별도 테이블에서 관리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 기존 테이블 구조 (마스터 데이터)
|
||||||
|
|
||||||
|
### 2-1. ER 다이어그램
|
||||||
|
|
||||||
|
> GitHub / VSCode Mermaid 플러그인에서 렌더링됩니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
%% ========== 마스터 데이터 (변경 없음) ==========
|
||||||
|
|
||||||
|
item_info {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar item_number "품번"
|
||||||
|
varchar item_name "품명"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
item_routing_version {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar item_code "품번 (= item_info.item_number)"
|
||||||
|
varchar version_name "버전명"
|
||||||
|
boolean is_default "기본버전 여부"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
item_routing_detail {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar routing_version_id FK "→ item_routing_version.id"
|
||||||
|
varchar seq_no "공정순서 10,20,30..."
|
||||||
|
varchar process_code FK "→ process_mng.process_code"
|
||||||
|
varchar is_required "필수/선택"
|
||||||
|
varchar is_fixed_order "고정/선택"
|
||||||
|
varchar standard_time "표준시간(분)"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
process_mng {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar process_code "공정코드"
|
||||||
|
varchar process_name "공정명"
|
||||||
|
varchar process_type "공정유형"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
process_work_item {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar routing_detail_id FK "→ item_routing_detail.id"
|
||||||
|
varchar work_phase "PRE / IN / POST"
|
||||||
|
varchar title "작업항목명"
|
||||||
|
varchar is_required "Y/N"
|
||||||
|
int sort_order "정렬순서"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
process_work_item_detail {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar work_item_id FK "→ process_work_item.id"
|
||||||
|
varchar detail_type "check/inspect/input/procedure/info"
|
||||||
|
varchar content "내용"
|
||||||
|
varchar input_type "입력타입"
|
||||||
|
varchar inspection_code "검사코드"
|
||||||
|
varchar unit "단위"
|
||||||
|
varchar lower_limit "하한값"
|
||||||
|
varchar upper_limit "상한값"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
%% ========== 트랜잭션 데이터 ==========
|
||||||
|
|
||||||
|
work_instruction {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar work_instruction_no "작업지시번호"
|
||||||
|
varchar item_id FK "→ item_info.id ★핵심★"
|
||||||
|
varchar status "waiting/in_progress/completed/cancelled"
|
||||||
|
varchar qty "지시수량"
|
||||||
|
varchar completed_qty "완성수량"
|
||||||
|
varchar worker "작업자"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
work_order_process {
|
||||||
|
varchar id PK "UUID"
|
||||||
|
varchar wo_id FK "→ work_instruction.id"
|
||||||
|
varchar routing_detail_id FK "→ item_routing_detail.id ★추가★"
|
||||||
|
varchar seq_no "공정순서"
|
||||||
|
varchar process_code "공정코드"
|
||||||
|
varchar process_name "공정명"
|
||||||
|
varchar status "waiting/in_progress/completed/skipped"
|
||||||
|
varchar plan_qty "계획수량"
|
||||||
|
varchar good_qty "양품수량"
|
||||||
|
varchar defect_qty "불량수량"
|
||||||
|
timestamp started_at "시작시간"
|
||||||
|
timestamp completed_at "완료시간"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
}
|
||||||
|
|
||||||
|
work_order_work_item {
|
||||||
|
varchar id PK "UUID ★신규★"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
varchar work_order_process_id FK "→ work_order_process.id"
|
||||||
|
varchar work_item_id FK "→ process_work_item.id"
|
||||||
|
varchar work_phase "PRE/IN/POST"
|
||||||
|
varchar status "pending/completed/skipped/failed"
|
||||||
|
varchar completed_by "완료자"
|
||||||
|
timestamp completed_at "완료시간"
|
||||||
|
}
|
||||||
|
|
||||||
|
work_order_work_item_result {
|
||||||
|
varchar id PK "UUID ★신규★"
|
||||||
|
varchar company_code "회사코드"
|
||||||
|
varchar work_order_work_item_id FK "→ work_order_work_item.id"
|
||||||
|
varchar work_item_detail_id FK "→ process_work_item_detail.id"
|
||||||
|
varchar detail_type "check/inspect/input/procedure"
|
||||||
|
varchar result_value "결과값"
|
||||||
|
varchar is_passed "Y/N/null"
|
||||||
|
varchar recorded_by "기록자"
|
||||||
|
timestamp recorded_at "기록시간"
|
||||||
|
}
|
||||||
|
|
||||||
|
%% ========== 관계 ==========
|
||||||
|
|
||||||
|
%% 마스터 체인: 품목 → 라우팅 → 작업기준정보
|
||||||
|
item_info ||--o{ item_routing_version : "item_number = item_code"
|
||||||
|
item_routing_version ||--o{ item_routing_detail : "id = routing_version_id"
|
||||||
|
item_routing_detail }o--|| process_mng : "process_code"
|
||||||
|
item_routing_detail ||--o{ process_work_item : "id = routing_detail_id"
|
||||||
|
process_work_item ||--o{ process_work_item_detail : "id = work_item_id"
|
||||||
|
|
||||||
|
%% 트랜잭션: 작업지시 → 공정진행 → 작업기준정보 진행
|
||||||
|
work_instruction }o--|| item_info : "item_id = id"
|
||||||
|
work_instruction ||--o{ work_order_process : "id = wo_id"
|
||||||
|
work_order_process }o--|| item_routing_detail : "routing_detail_id = id"
|
||||||
|
work_order_process ||--o{ work_order_work_item : "id = work_order_process_id"
|
||||||
|
work_order_work_item }o--|| process_work_item : "work_item_id = id"
|
||||||
|
work_order_work_item ||--o{ work_order_work_item_result : "id = work_order_work_item_id"
|
||||||
|
work_order_work_item_result }o--|| process_work_item_detail : "work_item_detail_id = id"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-1-1. 관계 요약 (텍스트)
|
||||||
|
|
||||||
|
```
|
||||||
|
[마스터 데이터 체인 - 조회용, 변경 없음]
|
||||||
|
|
||||||
|
item_info ─── 1:N ───→ item_routing_version ─── 1:N ───→ item_routing_detail
|
||||||
|
(품목) item_number (라우팅 버전) routing_ (공정별 상세)
|
||||||
|
= item_code version_id
|
||||||
|
│
|
||||||
|
process_mng ◄───┘ process_code (공정 마스터)
|
||||||
|
│
|
||||||
|
├── 1:N ───→ process_work_item ─── 1:N ───→ process_work_item_detail
|
||||||
|
│ (작업기준정보) (작업기준정보 상세)
|
||||||
|
│ routing_detail_id work_item_id
|
||||||
|
│
|
||||||
|
[트랜잭션 데이터 - 상태 관리] │
|
||||||
|
│
|
||||||
|
work_instruction ─── 1:N ───→ work_order_process ─┘ routing_detail_id (★추가★)
|
||||||
|
(작업지시) wo_id (공정별 진행)
|
||||||
|
item_id → item_info │
|
||||||
|
├── 1:N ───→ work_order_work_item ─── 1:N ───→ work_order_work_item_result
|
||||||
|
│ (작업기준정보 진행) (상세 결과값)
|
||||||
|
│ work_order_process_id work_order_work_item_id
|
||||||
|
│ work_item_id → process_work_item work_item_detail_id → process_work_item_detail
|
||||||
|
│ ★신규 테이블★ ★신규 테이블★
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-2. 마스터 테이블 상세
|
||||||
|
|
||||||
|
#### item_info (품목 마스터)
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| item_number | 품번 | item_routing_version.item_code와 매칭 |
|
||||||
|
| item_name | 품명 | |
|
||||||
|
| company_code | 회사코드 | 멀티테넌시 |
|
||||||
|
|
||||||
|
#### item_routing_version (라우팅 버전)
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| item_code | 품번 | item_info.item_number와 매칭 |
|
||||||
|
| version_name | 버전명 | 예: "기본 라우팅", "버전2" |
|
||||||
|
| is_default | 기본 버전 여부 | true/false, 기본 버전을 사용 |
|
||||||
|
| company_code | 회사코드 | |
|
||||||
|
|
||||||
|
#### item_routing_detail (라우팅 상세 - 공정별)
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| routing_version_id | FK → item_routing_version.id | |
|
||||||
|
| seq_no | 공정 순서 | 10, 20, 30... |
|
||||||
|
| process_code | 공정코드 | FK → process_mng.process_code |
|
||||||
|
| is_required | 필수/선택 | "필수" / "선택" |
|
||||||
|
| is_fixed_order | 순서고정 여부 | "고정" / "선택" |
|
||||||
|
| work_type | 작업유형 | |
|
||||||
|
| standard_time | 표준시간(분) | |
|
||||||
|
| outsource_supplier | 외주업체 | |
|
||||||
|
| company_code | 회사코드 | |
|
||||||
|
|
||||||
|
#### process_work_item (작업기준정보)
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| routing_detail_id | FK → item_routing_detail.id | |
|
||||||
|
| work_phase | 작업단계 | PRE(작업전) / IN(작업중) / POST(작업후) |
|
||||||
|
| title | 작업항목명 | 예: "장비 체크", "소재 준비" |
|
||||||
|
| is_required | 필수여부 | Y/N |
|
||||||
|
| sort_order | 정렬순서 | |
|
||||||
|
| description | 설명 | |
|
||||||
|
| company_code | 회사코드 | |
|
||||||
|
|
||||||
|
#### process_work_item_detail (작업기준정보 상세)
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| work_item_id | FK → process_work_item.id | |
|
||||||
|
| detail_type | 상세유형 | check(체크) / inspect(검사) / input(입력) / procedure(절차) / info(정보) |
|
||||||
|
| content | 내용 | 예: "소음검사", "치수검사" |
|
||||||
|
| input_type | 입력타입 | select, text 등 |
|
||||||
|
| inspection_code | 검사코드 | |
|
||||||
|
| inspection_method | 검사방법 | |
|
||||||
|
| unit | 단위 | |
|
||||||
|
| lower_limit | 하한값 | |
|
||||||
|
| upper_limit | 상한값 | |
|
||||||
|
| is_required | 필수여부 | Y/N |
|
||||||
|
| sort_order | 정렬순서 | |
|
||||||
|
| company_code | 회사코드 | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 작업 진행 테이블 (트랜잭션 데이터)
|
||||||
|
|
||||||
|
### 3-1. work_instruction (작업지시) - 기존 테이블
|
||||||
|
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| work_instruction_no | 작업지시번호 | 예: WO-2026-001 |
|
||||||
|
| **item_id** | **FK → item_info.id** | **이것만으로 라우팅/작업기준정보 전부 조회 가능** |
|
||||||
|
| status | 작업지시 상태 | waiting / in_progress / completed / cancelled |
|
||||||
|
| qty | 지시수량 | |
|
||||||
|
| completed_qty | 완성수량 | |
|
||||||
|
| work_team | 작업팀 | |
|
||||||
|
| worker | 작업자 | |
|
||||||
|
| equipment_id | 설비 | |
|
||||||
|
| start_date | 시작일 | |
|
||||||
|
| end_date | 종료일 | |
|
||||||
|
| remark | 비고 | |
|
||||||
|
| company_code | 회사코드 | |
|
||||||
|
|
||||||
|
> **routing 컬럼**: 현재 존재하지만 사용하지 않음 (null). 라우팅 버전을 지정하고 싶으면 이 컬럼에 `item_routing_version.id`를 넣어 특정 버전을 지정할 수 있음. 없으면 `is_default=true` 버전 자동 사용.
|
||||||
|
|
||||||
|
### 3-2. work_order_process (공정별 진행) - 기존 테이블, 변경 필요
|
||||||
|
|
||||||
|
작업지시가 생성될 때, 해당 품목의 라우팅 공정을 복사해서 이 테이블에 INSERT.
|
||||||
|
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | |
|
||||||
|
| wo_id | FK → work_instruction.id | 작업지시 참조 |
|
||||||
|
| **routing_detail_id** | **FK → item_routing_detail.id** | **추가 필요 - 라우팅 상세 참조** |
|
||||||
|
| seq_no | 공정 순서 | 라우팅에서 복사 |
|
||||||
|
| process_code | 공정코드 | 라우팅에서 복사 |
|
||||||
|
| process_name | 공정명 | 라우팅에서 복사 (비정규화, 조회 편의) |
|
||||||
|
| is_required | 필수여부 | 라우팅에서 복사 |
|
||||||
|
| is_fixed_order | 순서고정 | 라우팅에서 복사 |
|
||||||
|
| standard_time | 표준시간 | 라우팅에서 복사 |
|
||||||
|
| **status** | **공정 상태** | **waiting / in_progress / completed / skipped** |
|
||||||
|
| plan_qty | 계획수량 | |
|
||||||
|
| input_qty | 투입수량 | |
|
||||||
|
| good_qty | 양품수량 | |
|
||||||
|
| defect_qty | 불량수량 | |
|
||||||
|
| equipment_code | 사용설비 | |
|
||||||
|
| accepted_by | 접수자 | |
|
||||||
|
| accepted_at | 접수시간 | |
|
||||||
|
| started_at | 시작시간 | |
|
||||||
|
| completed_at | 완료시간 | |
|
||||||
|
| remark | 비고 | |
|
||||||
|
| company_code | 회사코드 | |
|
||||||
|
|
||||||
|
### 3-3. work_order_work_item (작업기준정보별 진행) - 신규 테이블
|
||||||
|
|
||||||
|
POP에서 작업자가 각 작업기준정보 항목을 체크/입력할 때 사용.
|
||||||
|
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | gen_random_uuid() |
|
||||||
|
| company_code | 회사코드 | 멀티테넌시 |
|
||||||
|
| work_order_process_id | FK → work_order_process.id | 어떤 작업지시의 어떤 공정인지 |
|
||||||
|
| work_item_id | FK → process_work_item.id | 어떤 작업기준정보인지 |
|
||||||
|
| work_phase | 작업단계 | PRE / IN / POST (마스터에서 복사) |
|
||||||
|
| status | 완료상태 | pending / completed / skipped / failed |
|
||||||
|
| completed_by | 완료자 | 작업자 ID |
|
||||||
|
| completed_at | 완료시간 | |
|
||||||
|
| created_date | 생성일 | |
|
||||||
|
| updated_date | 수정일 | |
|
||||||
|
| writer | 작성자 | |
|
||||||
|
|
||||||
|
### 3-4. work_order_work_item_result (작업기준정보 상세 결과) - 신규 테이블
|
||||||
|
|
||||||
|
작업기준정보의 상세 항목(체크, 검사, 입력 등)에 대한 실제 결과값 저장.
|
||||||
|
|
||||||
|
| 컬럼 | 설명 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | PK (UUID) | gen_random_uuid() |
|
||||||
|
| company_code | 회사코드 | 멀티테넌시 |
|
||||||
|
| work_order_work_item_id | FK → work_order_work_item.id | |
|
||||||
|
| work_item_detail_id | FK → process_work_item_detail.id | 어떤 상세항목인지 |
|
||||||
|
| detail_type | 상세유형 | check / inspect / input / procedure (마스터에서 복사) |
|
||||||
|
| result_value | 결과값 | 체크: "Y"/"N", 검사: 측정값, 입력: 입력값 |
|
||||||
|
| is_passed | 합격여부 | Y / N / null(해당없음) |
|
||||||
|
| remark | 비고 | 불합격 사유 등 |
|
||||||
|
| recorded_by | 기록자 | |
|
||||||
|
| recorded_at | 기록시간 | |
|
||||||
|
| created_date | 생성일 | |
|
||||||
|
| updated_date | 수정일 | |
|
||||||
|
| writer | 작성자 | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. POP 데이터 플로우
|
||||||
|
|
||||||
|
### 4-1. 작업지시 등록 시 (ERP 측)
|
||||||
|
|
||||||
|
```
|
||||||
|
[작업지시 생성]
|
||||||
|
│
|
||||||
|
├── 1. work_instruction INSERT (item_id, qty, status='waiting' 등)
|
||||||
|
│
|
||||||
|
├── 2. item_id → item_info.item_number 조회
|
||||||
|
│
|
||||||
|
├── 3. item_number → item_routing_version 조회 (is_default=true 또는 지정 버전)
|
||||||
|
│
|
||||||
|
├── 4. routing_version_id → item_routing_detail 조회 (공정 목록)
|
||||||
|
│
|
||||||
|
└── 5. 각 공정별로 work_order_process INSERT
|
||||||
|
├── wo_id = work_instruction.id
|
||||||
|
├── routing_detail_id = item_routing_detail.id ← 핵심!
|
||||||
|
├── seq_no, process_code, process_name 복사
|
||||||
|
├── status = 'waiting'
|
||||||
|
└── plan_qty = work_instruction.qty
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4-2. POP 작업 조회 시
|
||||||
|
|
||||||
|
```
|
||||||
|
[POP 화면: 작업지시 선택]
|
||||||
|
│
|
||||||
|
├── 1. work_instruction 목록 조회 (status = 'waiting' or 'in_progress')
|
||||||
|
│
|
||||||
|
├── 2. 선택한 작업지시의 공정 목록 조회
|
||||||
|
│ SELECT wop.*, pm.process_name
|
||||||
|
│ FROM work_order_process wop
|
||||||
|
│ LEFT JOIN process_mng pm ON wop.process_code = pm.process_code
|
||||||
|
│ WHERE wop.wo_id = {작업지시ID}
|
||||||
|
│ ORDER BY CAST(wop.seq_no AS int)
|
||||||
|
│
|
||||||
|
└── 3. 선택한 공정의 작업기준정보 조회 (마스터 데이터 참조)
|
||||||
|
SELECT pwi.*, pwid.*
|
||||||
|
FROM process_work_item pwi
|
||||||
|
LEFT JOIN process_work_item_detail pwid ON pwi.id = pwid.work_item_id
|
||||||
|
WHERE pwi.routing_detail_id = {work_order_process.routing_detail_id}
|
||||||
|
ORDER BY pwi.work_phase, pwi.sort_order, pwid.sort_order
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4-3. POP 작업 실행 시
|
||||||
|
|
||||||
|
```
|
||||||
|
[작업자가 공정 시작]
|
||||||
|
│
|
||||||
|
├── 1. work_order_process UPDATE
|
||||||
|
│ SET status = 'in_progress', started_at = NOW(), accepted_by = {작업자}
|
||||||
|
│
|
||||||
|
├── 2. work_instruction UPDATE (첫 공정 시작 시)
|
||||||
|
│ SET status = 'in_progress'
|
||||||
|
│
|
||||||
|
├── 3. 작업기준정보 항목별 체크/입력 시
|
||||||
|
│ ├── work_order_work_item UPSERT (항목별 상태)
|
||||||
|
│ └── work_order_work_item_result UPSERT (상세 결과값)
|
||||||
|
│
|
||||||
|
└── 4. 공정 완료 시
|
||||||
|
├── work_order_process UPDATE
|
||||||
|
│ SET status = 'completed', completed_at = NOW(),
|
||||||
|
│ good_qty = {양품}, defect_qty = {불량}
|
||||||
|
│
|
||||||
|
└── (모든 공정 완료 시)
|
||||||
|
work_instruction UPDATE
|
||||||
|
SET status = 'completed', completed_qty = {최종양품}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 핵심 조회 쿼리
|
||||||
|
|
||||||
|
### 5-1. 작업지시 → 전체 공정 + 작업기준정보 한방 조회
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 작업지시의 공정별 진행 현황 + 작업기준정보
|
||||||
|
SELECT
|
||||||
|
wi.work_instruction_no,
|
||||||
|
wi.qty,
|
||||||
|
wi.status as wi_status,
|
||||||
|
ii.item_number,
|
||||||
|
ii.item_name,
|
||||||
|
wop.id as process_id,
|
||||||
|
wop.seq_no,
|
||||||
|
wop.process_code,
|
||||||
|
wop.process_name,
|
||||||
|
wop.status as process_status,
|
||||||
|
wop.plan_qty,
|
||||||
|
wop.good_qty,
|
||||||
|
wop.defect_qty,
|
||||||
|
wop.started_at,
|
||||||
|
wop.completed_at,
|
||||||
|
wop.routing_detail_id,
|
||||||
|
-- 작업기준정보는 routing_detail_id로 마스터 조회
|
||||||
|
pwi.id as work_item_id,
|
||||||
|
pwi.work_phase,
|
||||||
|
pwi.title as work_item_title,
|
||||||
|
pwi.is_required as work_item_required
|
||||||
|
FROM work_instruction wi
|
||||||
|
JOIN item_info ii ON wi.item_id = ii.id
|
||||||
|
JOIN work_order_process wop ON wi.id = wop.wo_id
|
||||||
|
LEFT JOIN process_work_item pwi ON wop.routing_detail_id = pwi.routing_detail_id
|
||||||
|
WHERE wi.id = $1
|
||||||
|
AND wi.company_code = $2
|
||||||
|
ORDER BY CAST(wop.seq_no AS int), pwi.work_phase, pwi.sort_order;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5-2. 특정 공정의 작업기준정보 + 진행 상태 조회
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- POP에서 특정 공정 선택 시: 마스터 + 진행 상태 조인
|
||||||
|
SELECT
|
||||||
|
pwi.id as work_item_id,
|
||||||
|
pwi.work_phase,
|
||||||
|
pwi.title,
|
||||||
|
pwi.is_required,
|
||||||
|
pwid.id as detail_id,
|
||||||
|
pwid.detail_type,
|
||||||
|
pwid.content,
|
||||||
|
pwid.input_type,
|
||||||
|
pwid.inspection_code,
|
||||||
|
pwid.inspection_method,
|
||||||
|
pwid.unit,
|
||||||
|
pwid.lower_limit,
|
||||||
|
pwid.upper_limit,
|
||||||
|
-- 진행 상태
|
||||||
|
wowi.status as item_status,
|
||||||
|
wowi.completed_by,
|
||||||
|
wowi.completed_at,
|
||||||
|
-- 결과값
|
||||||
|
wowir.result_value,
|
||||||
|
wowir.is_passed,
|
||||||
|
wowir.remark as result_remark
|
||||||
|
FROM process_work_item pwi
|
||||||
|
LEFT JOIN process_work_item_detail pwid
|
||||||
|
ON pwi.id = pwid.work_item_id
|
||||||
|
LEFT JOIN work_order_work_item wowi
|
||||||
|
ON wowi.work_item_id = pwi.id
|
||||||
|
AND wowi.work_order_process_id = $1 -- work_order_process.id
|
||||||
|
LEFT JOIN work_order_work_item_result wowir
|
||||||
|
ON wowir.work_order_work_item_id = wowi.id
|
||||||
|
AND wowir.work_item_detail_id = pwid.id
|
||||||
|
WHERE pwi.routing_detail_id = $2 -- work_order_process.routing_detail_id
|
||||||
|
ORDER BY
|
||||||
|
CASE pwi.work_phase WHEN 'PRE' THEN 1 WHEN 'IN' THEN 2 WHEN 'POST' THEN 3 END,
|
||||||
|
pwi.sort_order,
|
||||||
|
pwid.sort_order;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 변경사항 요약
|
||||||
|
|
||||||
|
### 6-1. 기존 테이블 변경
|
||||||
|
|
||||||
|
| 테이블 | 변경내용 |
|
||||||
|
|--------|---------|
|
||||||
|
| work_order_process | `routing_detail_id VARCHAR(500)` 컬럼 추가 |
|
||||||
|
|
||||||
|
### 6-2. 신규 테이블
|
||||||
|
|
||||||
|
| 테이블 | 용도 |
|
||||||
|
|--------|------|
|
||||||
|
| work_order_work_item | 작업지시 공정별 작업기준정보 진행 상태 |
|
||||||
|
| work_order_work_item_result | 작업기준정보 상세 항목의 실제 결과값 |
|
||||||
|
|
||||||
|
### 6-3. 건드리지 않는 것
|
||||||
|
|
||||||
|
| 테이블 | 이유 |
|
||||||
|
|--------|------|
|
||||||
|
| work_instruction | item_id만 있으면 충분. 라우팅/작업기준정보 ID 추가 불필요 |
|
||||||
|
| item_routing_version | 마스터 데이터, 변경 없음 |
|
||||||
|
| item_routing_detail | 마스터 데이터, 변경 없음 |
|
||||||
|
| process_work_item | 마스터 데이터, 변경 없음 |
|
||||||
|
| process_work_item_detail | 마스터 데이터, 변경 없음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. DDL (마이그레이션 SQL)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. work_order_process에 routing_detail_id 추가
|
||||||
|
ALTER TABLE work_order_process
|
||||||
|
ADD COLUMN IF NOT EXISTS routing_detail_id VARCHAR(500);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wop_routing_detail_id
|
||||||
|
ON work_order_process(routing_detail_id);
|
||||||
|
|
||||||
|
-- 2. 작업기준정보별 진행 상태 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS work_order_work_item (
|
||||||
|
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
company_code VARCHAR(500) NOT NULL,
|
||||||
|
work_order_process_id VARCHAR(500) NOT NULL,
|
||||||
|
work_item_id VARCHAR(500) NOT NULL,
|
||||||
|
work_phase VARCHAR(500),
|
||||||
|
status VARCHAR(500) DEFAULT 'pending',
|
||||||
|
completed_by VARCHAR(500),
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
created_date TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_date TIMESTAMP DEFAULT NOW(),
|
||||||
|
writer VARCHAR(500)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wowi_process_id ON work_order_work_item(work_order_process_id);
|
||||||
|
CREATE INDEX idx_wowi_work_item_id ON work_order_work_item(work_item_id);
|
||||||
|
CREATE INDEX idx_wowi_company_code ON work_order_work_item(company_code);
|
||||||
|
|
||||||
|
-- 3. 작업기준정보 상세 결과 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS work_order_work_item_result (
|
||||||
|
id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
|
company_code VARCHAR(500) NOT NULL,
|
||||||
|
work_order_work_item_id VARCHAR(500) NOT NULL,
|
||||||
|
work_item_detail_id VARCHAR(500) NOT NULL,
|
||||||
|
detail_type VARCHAR(500),
|
||||||
|
result_value VARCHAR(500),
|
||||||
|
is_passed VARCHAR(500),
|
||||||
|
remark TEXT,
|
||||||
|
recorded_by VARCHAR(500),
|
||||||
|
recorded_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
created_date TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_date TIMESTAMP DEFAULT NOW(),
|
||||||
|
writer VARCHAR(500)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wowir_work_order_work_item_id ON work_order_work_item_result(work_order_work_item_id);
|
||||||
|
CREATE INDEX idx_wowir_detail_id ON work_order_work_item_result(work_item_detail_id);
|
||||||
|
CREATE INDEX idx_wowir_company_code ON work_order_work_item_result(company_code);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 상태값 정의
|
||||||
|
|
||||||
|
### work_instruction.status (작업지시 상태)
|
||||||
|
| 값 | 의미 |
|
||||||
|
|----|------|
|
||||||
|
| waiting | 대기 |
|
||||||
|
| in_progress | 진행중 |
|
||||||
|
| completed | 완료 |
|
||||||
|
| cancelled | 취소 |
|
||||||
|
|
||||||
|
### work_order_process.status (공정 상태)
|
||||||
|
| 값 | 의미 |
|
||||||
|
|----|------|
|
||||||
|
| waiting | 대기 (아직 시작 안 함) |
|
||||||
|
| in_progress | 진행중 (작업자가 시작) |
|
||||||
|
| completed | 완료 |
|
||||||
|
| skipped | 건너뜀 (선택 공정인 경우) |
|
||||||
|
|
||||||
|
### work_order_work_item.status (작업기준정보 항목 상태)
|
||||||
|
| 값 | 의미 |
|
||||||
|
|----|------|
|
||||||
|
| pending | 미완료 |
|
||||||
|
| completed | 완료 |
|
||||||
|
| skipped | 건너뜀 |
|
||||||
|
| failed | 실패 (검사 불합격 등) |
|
||||||
|
|
||||||
|
### work_order_work_item_result.is_passed (검사 합격여부)
|
||||||
|
| 값 | 의미 |
|
||||||
|
|----|------|
|
||||||
|
| Y | 합격 |
|
||||||
|
| N | 불합격 |
|
||||||
|
| null | 해당없음 (체크/입력 항목) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 설계 의도 요약
|
||||||
|
|
||||||
|
1. **마스터와 트랜잭션 분리**: 라우팅/작업기준정보는 마스터(템플릿), 실제 진행은 트랜잭션 테이블에서 관리
|
||||||
|
2. **조회 경로**: `work_instruction.item_id` → `item_info.item_number` → `item_routing_version` → `item_routing_detail` → `process_work_item` → `process_work_item_detail`
|
||||||
|
3. **진행 경로**: `work_order_process.routing_detail_id`로 마스터 작업기준정보를 참조하되, 실제 진행/결과는 `work_order_work_item` + `work_order_work_item_result`에 저장
|
||||||
|
4. **중복 저장 최소화**: 작업지시에 공정/작업기준정보 ID를 넣지 않음. 품목만 있으면 전부 파생 조회 가능
|
||||||
|
5. **work_order_process**: 작업지시 생성 시 라우팅 공정을 복사하는 이유는 진행 중 수량/상태/시간 등 트랜잭션 데이터를 기록해야 하기 때문 (마스터가 변경되어도 이미 발행된 작업지시의 공정은 유지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 주의사항
|
||||||
|
|
||||||
|
- `work_order_process`에 공정 정보를 복사(스냅샷)하는 이유: 마스터 라우팅이 나중에 변경되어도 이미 진행 중인 작업지시의 공정 구성은 영향받지 않아야 함
|
||||||
|
- `routing_detail_id`는 "이 공정이 어떤 마스터 라우팅에서 왔는지" 추적용. 작업기준정보 조회 키로 사용
|
||||||
|
- POP에서 작업기준정보를 표시할 때는 항상 마스터(`process_work_item`)를 조회하고, 결과만 트랜잭션 테이블에 저장
|
||||||
|
- 모든 테이블에 `company_code` 필수 (멀티테넌시)
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2 품목별 라우팅 설정 패널
|
* V2 품목별 라우팅 설정 패널
|
||||||
* 토스식 단계별 UX: 데이터 소스 -> 모달 연동 -> 공정 컬럼 -> 레이아웃(접힘)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
|
@ -16,10 +15,10 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Settings, ChevronDown, ChevronRight, Plus, Trash2, Check, ChevronsUpDown,
|
Settings, ChevronDown, ChevronRight, Plus, Trash2, Check, ChevronsUpDown,
|
||||||
Database, Monitor, Columns,
|
Database, Monitor, Columns, List, Filter, Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ItemRoutingConfig, ProcessColumnDef } from "@/lib/registry/components/v2-item-routing/types";
|
import type { ItemRoutingConfig, ProcessColumnDef, ColumnDef, ItemFilterCondition } from "@/lib/registry/components/v2-item-routing/types";
|
||||||
import { defaultConfig } from "@/lib/registry/components/v2-item-routing/config";
|
import { defaultConfig } from "@/lib/registry/components/v2-item-routing/config";
|
||||||
|
|
||||||
interface V2ItemRoutingConfigPanelProps {
|
interface V2ItemRoutingConfigPanelProps {
|
||||||
|
|
@ -27,53 +26,21 @@ interface V2ItemRoutingConfigPanelProps {
|
||||||
onChange: (config: Partial<ItemRoutingConfig>) => void;
|
onChange: (config: Partial<ItemRoutingConfig>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TableInfo {
|
interface TableInfo { tableName: string; displayName?: string; }
|
||||||
tableName: string;
|
interface ColumnInfo { columnName: string; displayName?: string; dataType?: string; }
|
||||||
displayName?: string;
|
interface ScreenInfo { screenId: number; screenName: string; screenCode: string; }
|
||||||
}
|
|
||||||
|
|
||||||
interface ColumnInfo {
|
// ─── 공용: 테이블 Combobox ───
|
||||||
columnName: string;
|
function TableCombobox({ value, onChange, tables, loading }: {
|
||||||
displayName?: string;
|
value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean;
|
||||||
dataType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScreenInfo {
|
|
||||||
screenId: number;
|
|
||||||
screenName: string;
|
|
||||||
screenCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 테이블 Combobox ───
|
|
||||||
function TableCombobox({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
tables,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
tables: TableInfo[];
|
|
||||||
loading: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const selected = tables.find((t) => t.tableName === value);
|
const selected = tables.find((t) => t.tableName === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading}>
|
||||||
variant="outline"
|
{loading ? "로딩 중..." : selected ? selected.displayName || selected.tableName : "테이블 선택"}
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
className="h-7 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" />
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -84,12 +51,8 @@ function TableCombobox({
|
||||||
<CommandEmpty className="py-4 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="py-4 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
{tables.map((t) => (
|
{tables.map((t) => (
|
||||||
<CommandItem
|
<CommandItem key={t.tableName} value={`${t.displayName || ""} ${t.tableName}`}
|
||||||
key={t.tableName}
|
onSelect={() => { onChange(t.tableName); setOpen(false); }} className="text-xs">
|
||||||
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")} />
|
<Check className={cn("mr-2 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{t.displayName || t.tableName}</span>
|
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||||
|
|
@ -105,17 +68,9 @@ function TableCombobox({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 컬럼 Combobox ───
|
// ─── 공용: 컬럼 Combobox ───
|
||||||
function ColumnCombobox({
|
function ColumnCombobox({ value, onChange, tableName, placeholder }: {
|
||||||
value,
|
value: string; onChange: (v: string, displayName?: string) => void; tableName: string; placeholder?: string;
|
||||||
onChange,
|
|
||||||
tableName,
|
|
||||||
placeholder,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
tableName: string;
|
|
||||||
placeholder?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
|
@ -128,26 +83,17 @@ function ColumnCombobox({
|
||||||
try {
|
try {
|
||||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
const res = await tableManagementApi.getColumnList(tableName);
|
const res = await tableManagementApi.getColumnList(tableName);
|
||||||
if (res.success && res.data?.columns) {
|
if (res.success && res.data?.columns) setColumns(res.data.columns);
|
||||||
setColumns(res.data.columns);
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ } finally { setLoading(false); }
|
} catch { /* ignore */ } finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
}, [tableName]);
|
}, [tableName]);
|
||||||
|
|
||||||
const selected = columns.find((c) => c.columnName === value);
|
const selected = columns.find((c) => c.columnName === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading || !tableName}>
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
className="h-7 w-full justify-between text-xs"
|
|
||||||
disabled={loading || !tableName}
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{loading ? "로딩..." : !tableName ? "테이블 먼저 선택" : selected ? selected.displayName || selected.columnName : placeholder || "컬럼 선택"}
|
{loading ? "로딩..." : !tableName ? "테이블 먼저 선택" : selected ? selected.displayName || selected.columnName : placeholder || "컬럼 선택"}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -161,12 +107,8 @@ function ColumnCombobox({
|
||||||
<CommandEmpty className="py-4 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="py-4 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
{columns.map((c) => (
|
{columns.map((c) => (
|
||||||
<CommandItem
|
<CommandItem key={c.columnName} value={`${c.displayName || ""} ${c.columnName}`}
|
||||||
key={c.columnName}
|
onSelect={() => { onChange(c.columnName, c.displayName); setOpen(false); }} className="text-xs">
|
||||||
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")} />
|
<Check className={cn("mr-2 h-3 w-3", value === c.columnName ? "opacity-100" : "opacity-0")} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{c.displayName || c.columnName}</span>
|
<span className="font-medium">{c.displayName || c.columnName}</span>
|
||||||
|
|
@ -182,14 +124,8 @@ function ColumnCombobox({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 화면 Combobox ───
|
// ─── 공용: 화면 Combobox ───
|
||||||
function ScreenCombobox({
|
function ScreenCombobox({ value, onChange }: { value?: number; onChange: (v?: number) => void; }) {
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value?: number;
|
|
||||||
onChange: (v?: number) => void;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -201,13 +137,9 @@ function ScreenCombobox({
|
||||||
const { screenApi } = await import("@/lib/api/screen");
|
const { screenApi } = await import("@/lib/api/screen");
|
||||||
const res = await screenApi.getScreens({ page: 1, size: 1000 });
|
const res = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
setScreens(
|
setScreens(res.data.map((s: any) => ({
|
||||||
res.data.map((s: any) => ({
|
screenId: s.screenId, screenName: s.screenName || `화면 ${s.screenId}`, screenCode: s.screenCode || "",
|
||||||
screenId: s.screenId,
|
})));
|
||||||
screenName: s.screenName || `화면 ${s.screenId}`,
|
|
||||||
screenCode: s.screenCode || "",
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ } finally { setLoading(false); }
|
} catch { /* ignore */ } finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
@ -215,20 +147,11 @@ function ScreenCombobox({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selected = screens.find((s) => s.screenId === value);
|
const selected = screens.find((s) => s.screenId === value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading}>
|
||||||
variant="outline"
|
<span className="truncate">{loading ? "로딩..." : selected ? selected.screenName : "화면 선택"}</span>
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
className="h-7 w-full justify-between text-xs"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{loading ? "로딩..." : selected ? selected.screenName : "화면 선택"}
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -239,12 +162,8 @@ function ScreenCombobox({
|
||||||
<CommandEmpty className="py-4 text-center text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="py-4 text-center text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
{screens.map((s) => (
|
{screens.map((s) => (
|
||||||
<CommandItem
|
<CommandItem key={s.screenId} value={`${s.screenName} ${s.screenCode} ${s.screenId}`}
|
||||||
key={s.screenId}
|
onSelect={() => { onChange(s.screenId); setOpen(false); }} className="text-xs">
|
||||||
value={`${s.screenName} ${s.screenCode} ${s.screenId}`}
|
|
||||||
onSelect={() => { onChange(s.screenId); setOpen(false); }}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Check className={cn("mr-2 h-3 w-3", value === s.screenId ? "opacity-100" : "opacity-0")} />
|
<Check className={cn("mr-2 h-3 w-3", value === s.screenId ? "opacity-100" : "opacity-0")} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{s.screenName}</span>
|
<span className="font-medium">{s.screenName}</span>
|
||||||
|
|
@ -260,17 +179,104 @@ function ScreenCombobox({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 컬럼 편집 카드 (품목/모달/공정 공용) ───
|
||||||
|
function ColumnEditor({ columns, onChange, tableName, title, icon }: {
|
||||||
|
columns: ColumnDef[];
|
||||||
|
onChange: (cols: ColumnDef[]) => void;
|
||||||
|
tableName: string;
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const addColumn = () => onChange([...columns, { name: "", label: "새 컬럼", width: 100, align: "left" }]);
|
||||||
|
const removeColumn = (idx: number) => onChange(columns.filter((_, i) => i !== idx));
|
||||||
|
const updateColumn = (idx: number, field: keyof ColumnDef, value: string | number) => {
|
||||||
|
const next = [...columns];
|
||||||
|
next[idx] = { ...next[idx], [field]: value };
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon}
|
||||||
|
<span className="text-sm font-medium">{title}</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px] h-5">{columns.length}개</Badge>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", open && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<Collapsible key={idx}>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button type="button" className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors">
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
|
||||||
|
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
|
||||||
|
<span className="text-xs font-medium truncate flex-1 min-w-0">{col.label || col.name || "미설정"}</span>
|
||||||
|
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{col.name || "?"}</Badge>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
onClick={(e) => { e.stopPropagation(); removeColumn(idx); }}
|
||||||
|
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">컬럼</span>
|
||||||
|
<ColumnCombobox value={col.name} onChange={(v, displayName) => {
|
||||||
|
updateColumn(idx, "name", v);
|
||||||
|
if (!col.label || col.label === "새 컬럼" || col.label === col.name) updateColumn(idx, "label", displayName || v);
|
||||||
|
}} tableName={tableName} placeholder="컬럼 선택" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">표시명</span>
|
||||||
|
<Input value={col.label} onChange={(e) => updateColumn(idx, "label", e.target.value)} className="h-7 text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">너비</span>
|
||||||
|
<Input type="number" value={col.width || 100} onChange={(e) => updateColumn(idx, "width", parseInt(e.target.value) || 100)} className="h-7 text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">정렬</span>
|
||||||
|
<Select value={col.align || "left"} onValueChange={(v) => updateColumn(idx, "align", v)}>
|
||||||
|
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="left">좌</SelectItem>
|
||||||
|
<SelectItem value="center">중</SelectItem>
|
||||||
|
<SelectItem value="right">우</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addColumn}>
|
||||||
|
<Plus className="h-3 w-3" /> 컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 메인 컴포넌트 ───
|
// ─── 메인 컴포넌트 ───
|
||||||
export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> = ({
|
export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> = ({ config: configProp, onChange }) => {
|
||||||
config: configProp,
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
const [loadingTables, setLoadingTables] = useState(false);
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [columnsOpen, setColumnsOpen] = useState(false);
|
|
||||||
const [dataSourceOpen, setDataSourceOpen] = useState(false);
|
const [dataSourceOpen, setDataSourceOpen] = useState(false);
|
||||||
const [layoutOpen, setLayoutOpen] = useState(false);
|
const [layoutOpen, setLayoutOpen] = useState(false);
|
||||||
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
|
||||||
const config: ItemRoutingConfig = {
|
const config: ItemRoutingConfig = {
|
||||||
...defaultConfig,
|
...defaultConfig,
|
||||||
|
|
@ -278,6 +284,9 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
||||||
modals: { ...defaultConfig.modals, ...configProp?.modals },
|
modals: { ...defaultConfig.modals, ...configProp?.modals },
|
||||||
processColumns: configProp?.processColumns?.length ? configProp.processColumns : defaultConfig.processColumns,
|
processColumns: configProp?.processColumns?.length ? configProp.processColumns : defaultConfig.processColumns,
|
||||||
|
itemDisplayColumns: configProp?.itemDisplayColumns?.length ? configProp.itemDisplayColumns : defaultConfig.itemDisplayColumns,
|
||||||
|
modalDisplayColumns: configProp?.modalDisplayColumns?.length ? configProp.modalDisplayColumns : defaultConfig.modalDisplayColumns,
|
||||||
|
itemFilterConditions: configProp?.itemFilterConditions || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -287,12 +296,7 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
const res = await tableManagementApi.getTableList();
|
const res = await tableManagementApi.getTableList();
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
setTables(
|
setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
|
||||||
res.data.map((t: any) => ({
|
|
||||||
tableName: t.tableName,
|
|
||||||
displayName: t.displayName || t.tableName,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ } finally { setLoadingTables(false); }
|
} catch { /* ignore */ } finally { setLoadingTables(false); }
|
||||||
};
|
};
|
||||||
|
|
@ -301,11 +305,7 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
|
|
||||||
const dispatchConfigEvent = (newConfig: Partial<ItemRoutingConfig>) => {
|
const dispatchConfigEvent = (newConfig: Partial<ItemRoutingConfig>) => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(new CustomEvent("componentConfigChanged", { detail: { config: { ...config, ...newConfig } } }));
|
||||||
new CustomEvent("componentConfigChanged", {
|
|
||||||
detail: { config: { ...config, ...newConfig } },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -316,61 +316,141 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDataSource = (field: string, value: string) => {
|
const updateDataSource = (field: string, value: string) => {
|
||||||
const newDataSource = { ...config.dataSource, [field]: value };
|
const newDS = { ...config.dataSource, [field]: value };
|
||||||
const partial = { dataSource: newDataSource };
|
onChange({ ...configProp, dataSource: newDS });
|
||||||
onChange({ ...configProp, ...partial });
|
dispatchConfigEvent({ dataSource: newDS });
|
||||||
dispatchConfigEvent(partial);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateModals = (field: string, value?: number) => {
|
const updateModals = (field: string, value?: number) => {
|
||||||
const newModals = { ...config.modals, [field]: value };
|
const newM = { ...config.modals, [field]: value };
|
||||||
const partial = { modals: newModals };
|
onChange({ ...configProp, modals: newM });
|
||||||
onChange({ ...configProp, ...partial });
|
dispatchConfigEvent({ modals: newM });
|
||||||
dispatchConfigEvent(partial);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 공정 컬럼 관리
|
// 필터 조건 관리
|
||||||
const addColumn = () => {
|
const filters = config.itemFilterConditions || [];
|
||||||
update({
|
const addFilter = () => update({ itemFilterConditions: [...filters, { column: "", operator: "equals", value: "" }] });
|
||||||
processColumns: [
|
const removeFilter = (idx: number) => update({ itemFilterConditions: filters.filter((_, i) => i !== idx) });
|
||||||
...config.processColumns,
|
const updateFilter = (idx: number, field: keyof ItemFilterCondition, val: string) => {
|
||||||
{ name: "", label: "새 컬럼", width: 100, align: "left" as const },
|
const next = [...filters];
|
||||||
],
|
next[idx] = { ...next[idx], [field]: val };
|
||||||
});
|
update({ itemFilterConditions: next });
|
||||||
};
|
|
||||||
|
|
||||||
const removeColumn = (idx: number) => {
|
|
||||||
update({ processColumns: config.processColumns.filter((_, i) => i !== idx) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateColumn = (idx: number, field: keyof ProcessColumnDef, value: string | number) => {
|
|
||||||
const next = [...config.processColumns];
|
|
||||||
next[idx] = { ...next[idx], [field]: value };
|
|
||||||
update({ processColumns: next });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* ─── 1단계: 모달 연동 (Collapsible) ─── */}
|
{/* ─── 품목 목록 모드 ─── */}
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<List className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">품목 목록 모드</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">좌측 품목 목록에 표시할 방식을 선택하세요</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button type="button"
|
||||||
|
className={cn("flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
|
||||||
|
(config.itemListMode || "all") === "all" ? "border-primary bg-primary/5 text-primary" : "border-input hover:bg-muted/50")}
|
||||||
|
onClick={() => update({ itemListMode: "all" })}>
|
||||||
|
<span className="font-medium">전체 품목</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">모든 품목 표시</span>
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
className={cn("flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
|
||||||
|
config.itemListMode === "registered" ? "border-primary bg-primary/5 text-primary" : "border-input hover:bg-muted/50")}
|
||||||
|
onClick={() => update({ itemListMode: "registered" })}>
|
||||||
|
<span className="font-medium">등록 품목만</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">선택한 품목만 표시</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{config.itemListMode === "registered" && (
|
||||||
|
<p className="text-[10px] text-muted-foreground pt-1">
|
||||||
|
현재 화면 ID를 기준으로 품목 목록이 자동 관리됩니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── 품목 표시 컬럼 ─── */}
|
||||||
|
<ColumnEditor
|
||||||
|
columns={config.itemDisplayColumns || []}
|
||||||
|
onChange={(cols) => update({ itemDisplayColumns: cols })}
|
||||||
|
tableName={config.dataSource.itemTable}
|
||||||
|
title="품목 목록 컬럼"
|
||||||
|
icon={<Eye className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ─── 모달 표시 컬럼 (등록 모드에서만 의미 있지만 항상 설정 가능) ─── */}
|
||||||
|
<ColumnEditor
|
||||||
|
columns={config.modalDisplayColumns || []}
|
||||||
|
onChange={(cols) => update({ modalDisplayColumns: cols })}
|
||||||
|
tableName={config.dataSource.itemTable}
|
||||||
|
title="품목 추가 모달 컬럼"
|
||||||
|
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ─── 품목 필터 조건 ─── */}
|
||||||
|
<Collapsible open={filterOpen} onOpenChange={setFilterOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">품목 필터 조건</span>
|
||||||
|
{filters.length > 0 && <Badge variant="secondary" className="text-[10px] h-5">{filters.length}건</Badge>}
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", filterOpen && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="rounded-b-lg border border-t-0 p-3 space-y-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground">품목 조회 시 자동으로 적용되는 필터 조건입니다</p>
|
||||||
|
{filters.map((f, idx) => (
|
||||||
|
<div key={idx} className="flex items-end gap-1.5 rounded-md border p-2">
|
||||||
|
<div className="flex-1 space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">컬럼</span>
|
||||||
|
<ColumnCombobox value={f.column} onChange={(v) => updateFilter(idx, "column", v)}
|
||||||
|
tableName={config.dataSource.itemTable} placeholder="필터 컬럼" />
|
||||||
|
</div>
|
||||||
|
<div className="w-[90px] space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">조건</span>
|
||||||
|
<Select value={f.operator} onValueChange={(v) => updateFilter(idx, "operator", v)}>
|
||||||
|
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="equals">같음</SelectItem>
|
||||||
|
<SelectItem value="contains">포함</SelectItem>
|
||||||
|
<SelectItem value="not_equals">다름</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">값</span>
|
||||||
|
<Input value={f.value} onChange={(e) => updateFilter(idx, "value", e.target.value)}
|
||||||
|
placeholder="필터값" className="h-7 text-xs" />
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
onClick={() => removeFilter(idx)}
|
||||||
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addFilter}>
|
||||||
|
<Plus className="h-3 w-3" /> 필터 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* ─── 모달 연동 ─── */}
|
||||||
<Collapsible open={modalOpen} onOpenChange={setModalOpen}>
|
<Collapsible open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">모달 연동</span>
|
<span className="text-sm font-medium">모달 연동</span>
|
||||||
<Badge variant="secondary" className="text-[10px] h-5">
|
<Badge variant="secondary" className="text-[10px] h-5">
|
||||||
{[config.modals.versionAddScreenId, config.modals.processAddScreenId, config.modals.processEditScreenId].filter(Boolean).length}개 설정됨
|
{[config.modals.versionAddScreenId, config.modals.processAddScreenId, config.modals.processEditScreenId].filter(Boolean).length}개
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", modalOpen && "rotate-180")} />
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
||||||
modalOpen && "rotate-180",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
|
|
@ -379,291 +459,103 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-muted-foreground">버전 추가</span>
|
<span className="text-[10px] text-muted-foreground">버전 추가</span>
|
||||||
<ScreenCombobox
|
<ScreenCombobox value={config.modals.versionAddScreenId} onChange={(v) => updateModals("versionAddScreenId", v)} />
|
||||||
value={config.modals.versionAddScreenId}
|
|
||||||
onChange={(v) => updateModals("versionAddScreenId", v)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-muted-foreground">공정 추가</span>
|
<span className="text-[10px] text-muted-foreground">공정 추가</span>
|
||||||
<ScreenCombobox
|
<ScreenCombobox value={config.modals.processAddScreenId} onChange={(v) => updateModals("processAddScreenId", v)} />
|
||||||
value={config.modals.processAddScreenId}
|
|
||||||
onChange={(v) => updateModals("processAddScreenId", v)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-muted-foreground">공정 수정</span>
|
<span className="text-[10px] text-muted-foreground">공정 수정</span>
|
||||||
<ScreenCombobox
|
<ScreenCombobox value={config.modals.processEditScreenId} onChange={(v) => updateModals("processEditScreenId", v)} />
|
||||||
value={config.modals.processEditScreenId}
|
|
||||||
onChange={(v) => updateModals("processEditScreenId", v)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* ─── 2단계: 공정 테이블 컬럼 (Collapsible + 접이식 카드) ─── */}
|
{/* ─── 공정 테이블 컬럼 ─── */}
|
||||||
<Collapsible open={columnsOpen} onOpenChange={setColumnsOpen}>
|
<ColumnEditor
|
||||||
<CollapsibleTrigger asChild>
|
columns={config.processColumns}
|
||||||
<button
|
onChange={(cols) => update({ processColumns: cols })}
|
||||||
type="button"
|
tableName={config.dataSource.routingDetailTable}
|
||||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
title="공정 테이블 컬럼"
|
||||||
>
|
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<Columns className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm font-medium">테이블 컬럼</span>
|
|
||||||
<Badge variant="secondary" className="text-[10px] h-5">
|
|
||||||
{config.processColumns.length}개
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
||||||
columnsOpen && "rotate-180",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
|
|
||||||
<p className="text-[10px] text-muted-foreground mb-1">공정 순서 테이블에 표시할 컬럼</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{config.processColumns.map((col, idx) => (
|
|
||||||
<Collapsible key={idx}>
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
|
|
||||||
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
|
|
||||||
<span className="text-xs font-medium truncate flex-1 min-w-0">{col.name || "미설정"}</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground truncate max-w-[60px] shrink-0">{col.label}</span>
|
|
||||||
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{col.align || "left"}</Badge>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => { e.stopPropagation(); removeColumn(idx); }}
|
|
||||||
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<span className="text-[10px] text-muted-foreground">컬럼명</span>
|
|
||||||
<Input
|
|
||||||
value={col.name}
|
|
||||||
onChange={(e) => updateColumn(idx, "name", e.target.value)}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
placeholder="컬럼명"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<span className="text-[10px] text-muted-foreground">표시명</span>
|
|
||||||
<Input
|
|
||||||
value={col.label}
|
|
||||||
onChange={(e) => updateColumn(idx, "label", e.target.value)}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
placeholder="표시명"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<span className="text-[10px] text-muted-foreground">너비</span>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={col.width || 100}
|
|
||||||
onChange={(e) => updateColumn(idx, "width", parseInt(e.target.value) || 100)}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
placeholder="100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<span className="text-[10px] text-muted-foreground">정렬</span>
|
|
||||||
<Select
|
|
||||||
value={col.align || "left"}
|
|
||||||
onValueChange={(v) => updateColumn(idx, "align", v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="left">좌</SelectItem>
|
|
||||||
<SelectItem value="center">중</SelectItem>
|
|
||||||
<SelectItem value="right">우</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</div>
|
|
||||||
</Collapsible>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-full gap-1 text-xs border-dashed"
|
|
||||||
onClick={addColumn}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
컬럼 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{/* ─── 3단계: 데이터 소스 (Collapsible) ─── */}
|
{/* ─── 데이터 소스 ─── */}
|
||||||
<Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
|
<Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="h-4 w-4 text-muted-foreground" />
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">데이터 소스 설정</span>
|
<span className="text-sm font-medium">데이터 소스 설정</span>
|
||||||
{config.dataSource.itemTable && (
|
{config.dataSource.itemTable && (
|
||||||
<Badge variant="secondary" className="text-[10px] h-5 truncate max-w-[100px]">
|
<Badge variant="secondary" className="text-[10px] h-5 truncate max-w-[100px]">{config.dataSource.itemTable}</Badge>
|
||||||
{config.dataSource.itemTable}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", dataSourceOpen && "rotate-180")} />
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
||||||
dataSourceOpen && "rotate-180"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">품목 테이블</span>
|
<span className="text-xs text-muted-foreground">품목 테이블</span>
|
||||||
<TableCombobox
|
<TableCombobox value={config.dataSource.itemTable} onChange={(v) => updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
|
||||||
value={config.dataSource.itemTable}
|
|
||||||
onChange={(v) => updateDataSource("itemTable", v)}
|
|
||||||
tables={tables}
|
|
||||||
loading={loadingTables}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">품목명 컬럼</span>
|
<span className="text-xs text-muted-foreground">품목명 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.itemNameColumn} onChange={(v) => updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
|
||||||
value={config.dataSource.itemNameColumn}
|
|
||||||
onChange={(v) => updateDataSource("itemNameColumn", v)}
|
|
||||||
tableName={config.dataSource.itemTable}
|
|
||||||
placeholder="품목명"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">품목코드 컬럼</span>
|
<span className="text-xs text-muted-foreground">품목코드 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.itemCodeColumn} onChange={(v) => updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
|
||||||
value={config.dataSource.itemCodeColumn}
|
|
||||||
onChange={(v) => updateDataSource("itemCodeColumn", v)}
|
|
||||||
tableName={config.dataSource.itemTable}
|
|
||||||
placeholder="품목코드"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 pt-2">
|
<div className="space-y-1 pt-2">
|
||||||
<span className="text-xs text-muted-foreground">라우팅 버전 테이블</span>
|
<span className="text-xs text-muted-foreground">라우팅 버전 테이블</span>
|
||||||
<TableCombobox
|
<TableCombobox value={config.dataSource.routingVersionTable} onChange={(v) => updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
|
||||||
value={config.dataSource.routingVersionTable}
|
|
||||||
onChange={(v) => updateDataSource("routingVersionTable", v)}
|
|
||||||
tables={tables}
|
|
||||||
loading={loadingTables}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">품목 FK 컬럼</span>
|
<span className="text-xs text-muted-foreground">품목 FK 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.routingVersionFkColumn} onChange={(v) => updateDataSource("routingVersionFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
|
||||||
value={config.dataSource.routingVersionFkColumn}
|
|
||||||
onChange={(v) => updateDataSource("routingVersionFkColumn", v)}
|
|
||||||
tableName={config.dataSource.routingVersionTable}
|
|
||||||
placeholder="FK 컬럼"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">버전명 컬럼</span>
|
<span className="text-xs text-muted-foreground">버전명 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.routingVersionNameColumn} onChange={(v) => updateDataSource("routingVersionNameColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="버전명" />
|
||||||
value={config.dataSource.routingVersionNameColumn}
|
|
||||||
onChange={(v) => updateDataSource("routingVersionNameColumn", v)}
|
|
||||||
tableName={config.dataSource.routingVersionTable}
|
|
||||||
placeholder="버전명"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 pt-2">
|
<div className="space-y-1 pt-2">
|
||||||
<span className="text-xs text-muted-foreground">라우팅 상세 테이블</span>
|
<span className="text-xs text-muted-foreground">라우팅 상세 테이블</span>
|
||||||
<TableCombobox
|
<TableCombobox value={config.dataSource.routingDetailTable} onChange={(v) => updateDataSource("routingDetailTable", v)} tables={tables} loading={loadingTables} />
|
||||||
value={config.dataSource.routingDetailTable}
|
|
||||||
onChange={(v) => updateDataSource("routingDetailTable", v)}
|
|
||||||
tables={tables}
|
|
||||||
loading={loadingTables}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">버전 FK 컬럼</span>
|
<span className="text-xs text-muted-foreground">버전 FK 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.routingDetailFkColumn} onChange={(v) => updateDataSource("routingDetailFkColumn", v)} tableName={config.dataSource.routingDetailTable} placeholder="FK 컬럼" />
|
||||||
value={config.dataSource.routingDetailFkColumn}
|
|
||||||
onChange={(v) => updateDataSource("routingDetailFkColumn", v)}
|
|
||||||
tableName={config.dataSource.routingDetailTable}
|
|
||||||
placeholder="FK 컬럼"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 pt-2">
|
<div className="space-y-1 pt-2">
|
||||||
<span className="text-xs text-muted-foreground">공정 마스터 테이블</span>
|
<span className="text-xs text-muted-foreground">공정 마스터 테이블</span>
|
||||||
<TableCombobox
|
<TableCombobox value={config.dataSource.processTable} onChange={(v) => updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
|
||||||
value={config.dataSource.processTable}
|
|
||||||
onChange={(v) => updateDataSource("processTable", v)}
|
|
||||||
tables={tables}
|
|
||||||
loading={loadingTables}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">공정명 컬럼</span>
|
<span className="text-xs text-muted-foreground">공정명 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.processNameColumn} onChange={(v) => updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
|
||||||
value={config.dataSource.processNameColumn}
|
|
||||||
onChange={(v) => updateDataSource("processNameColumn", v)}
|
|
||||||
tableName={config.dataSource.processTable}
|
|
||||||
placeholder="공정명"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-xs text-muted-foreground">공정코드 컬럼</span>
|
<span className="text-xs text-muted-foreground">공정코드 컬럼</span>
|
||||||
<ColumnCombobox
|
<ColumnCombobox value={config.dataSource.processCodeColumn} onChange={(v) => updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
|
||||||
value={config.dataSource.processCodeColumn}
|
|
||||||
onChange={(v) => updateDataSource("processCodeColumn", v)}
|
|
||||||
tableName={config.dataSource.processTable}
|
|
||||||
placeholder="공정코드"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* ─── 4단계: 레이아웃 & 기타 (Collapsible) ─── */}
|
{/* ─── 레이아웃 & 기타 ─── */}
|
||||||
<Collapsible open={layoutOpen} onOpenChange={setLayoutOpen}>
|
<Collapsible open={layoutOpen} onOpenChange={setLayoutOpen}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">레이아웃 & 기타</span>
|
<span className="text-sm font-medium">레이아웃 & 기타</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", layoutOpen && "rotate-180")} />
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
|
||||||
layoutOpen && "rotate-180"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
|
|
@ -673,76 +565,38 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
<span className="text-xs text-muted-foreground">좌측 패널 비율 (%)</span>
|
<span className="text-xs text-muted-foreground">좌측 패널 비율 (%)</span>
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">품목 목록 패널의 너비</p>
|
<p className="text-[10px] text-muted-foreground mt-0.5">품목 목록 패널의 너비</p>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input type="number" min={20} max={60} value={config.splitRatio || 40}
|
||||||
type="number"
|
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 40 })} className="h-7 w-[80px] text-xs" />
|
||||||
min={20}
|
|
||||||
max={60}
|
|
||||||
value={config.splitRatio || 40}
|
|
||||||
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 40 })}
|
|
||||||
className="h-7 w-[80px] text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">좌측 패널 제목</span>
|
<span className="text-xs text-muted-foreground">좌측 패널 제목</span>
|
||||||
<Input
|
<Input value={config.leftPanelTitle || ""} onChange={(e) => update({ leftPanelTitle: e.target.value })} placeholder="품목 목록" className="h-7 w-[140px] text-xs" />
|
||||||
value={config.leftPanelTitle || ""}
|
|
||||||
onChange={(e) => update({ leftPanelTitle: e.target.value })}
|
|
||||||
placeholder="품목 목록"
|
|
||||||
className="h-7 w-[140px] text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">우측 패널 제목</span>
|
<span className="text-xs text-muted-foreground">우측 패널 제목</span>
|
||||||
<Input
|
<Input value={config.rightPanelTitle || ""} onChange={(e) => update({ rightPanelTitle: e.target.value })} placeholder="공정 순서" className="h-7 w-[140px] text-xs" />
|
||||||
value={config.rightPanelTitle || ""}
|
|
||||||
onChange={(e) => update({ rightPanelTitle: e.target.value })}
|
|
||||||
placeholder="공정 순서"
|
|
||||||
className="h-7 w-[140px] text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">버전 추가 버튼 텍스트</span>
|
<span className="text-xs text-muted-foreground">버전 추가 버튼 텍스트</span>
|
||||||
<Input
|
<Input value={config.versionAddButtonText || ""} onChange={(e) => update({ versionAddButtonText: e.target.value })} placeholder="+ 라우팅 버전 추가" className="h-7 w-[140px] text-xs" />
|
||||||
value={config.versionAddButtonText || ""}
|
|
||||||
onChange={(e) => update({ versionAddButtonText: e.target.value })}
|
|
||||||
placeholder="+ 라우팅 버전 추가"
|
|
||||||
className="h-7 w-[140px] text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<span className="text-xs text-muted-foreground">공정 추가 버튼 텍스트</span>
|
<span className="text-xs text-muted-foreground">공정 추가 버튼 텍스트</span>
|
||||||
<Input
|
<Input value={config.processAddButtonText || ""} onChange={(e) => update({ processAddButtonText: e.target.value })} placeholder="+ 공정 추가" className="h-7 w-[140px] text-xs" />
|
||||||
value={config.processAddButtonText || ""}
|
|
||||||
onChange={(e) => update({ processAddButtonText: e.target.value })}
|
|
||||||
placeholder="+ 공정 추가"
|
|
||||||
className="h-7 w-[140px] text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm">첫 번째 버전 자동 선택</p>
|
<p className="text-sm">첫 번째 버전 자동 선택</p>
|
||||||
<p className="text-[11px] text-muted-foreground">품목 선택 시 첫 버전을 자동으로 선택해요</p>
|
<p className="text-[11px] text-muted-foreground">품목 선택 시 첫 버전을 자동으로 선택해요</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch checked={config.autoSelectFirstVersion !== false} onCheckedChange={(checked) => update({ autoSelectFirstVersion: checked })} />
|
||||||
checked={config.autoSelectFirstVersion !== false}
|
|
||||||
onCheckedChange={(checked) => update({ autoSelectFirstVersion: checked })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm">읽기 전용</p>
|
<p className="text-sm">읽기 전용</p>
|
||||||
<p className="text-[11px] text-muted-foreground">추가/수정/삭제 버튼을 숨겨요</p>
|
<p className="text-[11px] text-muted-foreground">추가/수정/삭제 버튼을 숨겨요</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch checked={config.readonly || false} onCheckedChange={(checked) => update({ readonly: checked })} />
|
||||||
checked={config.readonly || false}
|
|
||||||
onCheckedChange={(checked) => update({ readonly: checked })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
|
|
@ -752,5 +606,4 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
||||||
};
|
};
|
||||||
|
|
||||||
V2ItemRoutingConfigPanel.displayName = "V2ItemRoutingConfigPanel";
|
V2ItemRoutingConfigPanel.displayName = "V2ItemRoutingConfigPanel";
|
||||||
|
|
||||||
export default V2ItemRoutingConfigPanel;
|
export default V2ItemRoutingConfigPanel;
|
||||||
|
|
|
||||||
|
|
@ -1,217 +1,203 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } from "lucide-react";
|
import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
AlertDialogAction,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types";
|
import { ItemRoutingComponentProps, ColumnDef } from "./types";
|
||||||
import { defaultConfig } from "./config";
|
|
||||||
import { useItemRouting } from "./hooks/useItemRouting";
|
import { useItemRouting } from "./hooks/useItemRouting";
|
||||||
|
|
||||||
|
const DEFAULT_ITEM_COLS: ColumnDef[] = [
|
||||||
|
{ name: "item_name", label: "품명" },
|
||||||
|
{ name: "item_code", label: "품번", width: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
export function ItemRoutingComponent({
|
export function ItemRoutingComponent({
|
||||||
config: configProp,
|
config: configProp,
|
||||||
isPreview,
|
isPreview,
|
||||||
|
screenId,
|
||||||
}: ItemRoutingComponentProps) {
|
}: ItemRoutingComponentProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const resolvedConfig = React.useMemo(() => {
|
||||||
|
if (configProp?.itemListMode === "registered" && !configProp?.screenCode && screenId) {
|
||||||
|
return { ...configProp, screenCode: `screen_${screenId}` };
|
||||||
|
}
|
||||||
|
return configProp;
|
||||||
|
}, [configProp, screenId]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
config,
|
config, items, allItems, versions, details, loading,
|
||||||
items,
|
selectedItemCode, selectedItemName, selectedVersionId, isRegisteredMode,
|
||||||
versions,
|
fetchItems, fetchRegisteredItems, fetchAllItems,
|
||||||
details,
|
registerItemsBatch, unregisterItem,
|
||||||
loading,
|
selectItem, selectVersion, refreshVersions, refreshDetails,
|
||||||
selectedItemCode,
|
deleteDetail, deleteVersion, setDefaultVersion, unsetDefaultVersion,
|
||||||
selectedItemName,
|
} = useItemRouting(resolvedConfig || {});
|
||||||
selectedVersionId,
|
|
||||||
fetchItems,
|
|
||||||
selectItem,
|
|
||||||
selectVersion,
|
|
||||||
refreshVersions,
|
|
||||||
refreshDetails,
|
|
||||||
deleteDetail,
|
|
||||||
deleteVersion,
|
|
||||||
setDefaultVersion,
|
|
||||||
unsetDefaultVersion,
|
|
||||||
} = useItemRouting(configProp || {});
|
|
||||||
|
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{
|
const [deleteTarget, setDeleteTarget] = useState<{
|
||||||
type: "version" | "detail";
|
type: "version" | "detail"; id: string; name: string;
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// 초기 로딩 (마운트 시 1회만)
|
// 품목 추가 다이얼로그
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
|
const [addSearchText, setAddSearchText] = useState("");
|
||||||
|
const [selectedAddItems, setSelectedAddItems] = useState<Set<string>>(new Set());
|
||||||
|
const [addLoading, setAddLoading] = useState(false);
|
||||||
|
|
||||||
|
const itemDisplayCols = config.itemDisplayColumns?.length
|
||||||
|
? config.itemDisplayColumns : DEFAULT_ITEM_COLS;
|
||||||
|
const modalDisplayCols = config.modalDisplayColumns?.length
|
||||||
|
? config.modalDisplayColumns : DEFAULT_ITEM_COLS;
|
||||||
|
|
||||||
|
// 초기 로딩
|
||||||
const mountedRef = React.useRef(false);
|
const mountedRef = React.useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mountedRef.current) {
|
if (!mountedRef.current) {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
fetchItems();
|
if (isRegisteredMode) fetchRegisteredItems();
|
||||||
|
else fetchItems();
|
||||||
}
|
}
|
||||||
}, [fetchItems]);
|
}, [fetchItems, fetchRegisteredItems, isRegisteredMode]);
|
||||||
|
|
||||||
// 모달 저장 성공 감지 -> 데이터 새로고침
|
// 모달 저장 성공 감지
|
||||||
const refreshVersionsRef = React.useRef(refreshVersions);
|
const refreshVersionsRef = React.useRef(refreshVersions);
|
||||||
const refreshDetailsRef = React.useRef(refreshDetails);
|
const refreshDetailsRef = React.useRef(refreshDetails);
|
||||||
refreshVersionsRef.current = refreshVersions;
|
refreshVersionsRef.current = refreshVersions;
|
||||||
refreshDetailsRef.current = refreshDetails;
|
refreshDetailsRef.current = refreshDetails;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSaveSuccess = () => {
|
const h = () => { refreshVersionsRef.current(); refreshDetailsRef.current(); };
|
||||||
refreshVersionsRef.current();
|
window.addEventListener("saveSuccessInModal", h);
|
||||||
refreshDetailsRef.current();
|
return () => window.removeEventListener("saveSuccessInModal", h);
|
||||||
};
|
|
||||||
window.addEventListener("saveSuccessInModal", handleSaveSuccess);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 품목 검색
|
// 검색
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(() => {
|
||||||
fetchItems(searchText || undefined);
|
if (isRegisteredMode) fetchRegisteredItems(searchText || undefined);
|
||||||
}, [fetchItems, searchText]);
|
else fetchItems(searchText || undefined);
|
||||||
|
}, [fetchItems, fetchRegisteredItems, isRegisteredMode, searchText]);
|
||||||
|
|
||||||
const handleSearchKeyDown = useCallback(
|
// ──── 품목 추가 모달 ────
|
||||||
(e: React.KeyboardEvent) => {
|
const handleOpenAddDialog = useCallback(() => {
|
||||||
if (e.key === "Enter") handleSearch();
|
setAddSearchText(""); setSelectedAddItems(new Set()); setAddDialogOpen(true);
|
||||||
|
fetchAllItems();
|
||||||
|
}, [fetchAllItems]);
|
||||||
|
|
||||||
|
const handleToggleAddItem = useCallback((itemId: string) => {
|
||||||
|
setSelectedAddItems((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(itemId) ? next.delete(itemId) : next.add(itemId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirmAdd = useCallback(async () => {
|
||||||
|
if (selectedAddItems.size === 0) return;
|
||||||
|
setAddLoading(true);
|
||||||
|
const itemList = allItems
|
||||||
|
.filter((item) => selectedAddItems.has(item.id))
|
||||||
|
.map((item) => ({
|
||||||
|
itemId: item.id,
|
||||||
|
itemCode: item.item_code || item[config.dataSource.itemCodeColumn] || "",
|
||||||
|
}));
|
||||||
|
const success = await registerItemsBatch(itemList);
|
||||||
|
setAddLoading(false);
|
||||||
|
if (success) {
|
||||||
|
toast({ title: `${itemList.length}개 품목이 등록되었습니다` });
|
||||||
|
setAddDialogOpen(false);
|
||||||
|
} else {
|
||||||
|
toast({ title: "품목 등록 실패", variant: "destructive" });
|
||||||
|
}
|
||||||
|
}, [selectedAddItems, allItems, config.dataSource.itemCodeColumn, registerItemsBatch, toast]);
|
||||||
|
|
||||||
|
const handleUnregisterItem = useCallback(
|
||||||
|
async (registeredId: string, itemName: string) => {
|
||||||
|
const success = await unregisterItem(registeredId);
|
||||||
|
if (success) toast({ title: `${itemName} 등록 해제됨` });
|
||||||
|
else toast({ title: "등록 해제 실패", variant: "destructive" });
|
||||||
},
|
},
|
||||||
[handleSearch]
|
[unregisterItem, toast]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 버전 추가 모달
|
// ──── 기존 핸들러 ────
|
||||||
const handleAddVersion = useCallback(() => {
|
const handleAddVersion = useCallback(() => {
|
||||||
if (!selectedItemCode) {
|
if (!selectedItemCode) { toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" }); return; }
|
||||||
toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" });
|
const sid = config.modals.versionAddScreenId;
|
||||||
return;
|
if (!sid) return;
|
||||||
}
|
window.dispatchEvent(new CustomEvent("openScreenModal", {
|
||||||
const screenId = config.modals.versionAddScreenId;
|
detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable },
|
||||||
if (!screenId) return;
|
splitPanelParentData: { [config.dataSource.routingVersionFkColumn]: selectedItemCode } },
|
||||||
|
}));
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("openScreenModal", {
|
|
||||||
detail: {
|
|
||||||
screenId,
|
|
||||||
urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable },
|
|
||||||
splitPanelParentData: {
|
|
||||||
[config.dataSource.routingVersionFkColumn]: selectedItemCode,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [selectedItemCode, config, toast]);
|
}, [selectedItemCode, config, toast]);
|
||||||
|
|
||||||
// 공정 추가 모달
|
|
||||||
const handleAddProcess = useCallback(() => {
|
const handleAddProcess = useCallback(() => {
|
||||||
if (!selectedVersionId) {
|
if (!selectedVersionId) { toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" }); return; }
|
||||||
toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" });
|
const sid = config.modals.processAddScreenId;
|
||||||
return;
|
if (!sid) return;
|
||||||
}
|
window.dispatchEvent(new CustomEvent("openScreenModal", {
|
||||||
const screenId = config.modals.processAddScreenId;
|
detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
|
||||||
if (!screenId) return;
|
splitPanelParentData: { [config.dataSource.routingDetailFkColumn]: selectedVersionId } },
|
||||||
|
}));
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("openScreenModal", {
|
|
||||||
detail: {
|
|
||||||
screenId,
|
|
||||||
urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
|
|
||||||
splitPanelParentData: {
|
|
||||||
[config.dataSource.routingDetailFkColumn]: selectedVersionId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [selectedVersionId, config, toast]);
|
}, [selectedVersionId, config, toast]);
|
||||||
|
|
||||||
// 공정 수정 모달
|
|
||||||
const handleEditProcess = useCallback(
|
const handleEditProcess = useCallback(
|
||||||
(detail: Record<string, any>) => {
|
(detail: Record<string, any>) => {
|
||||||
const screenId = config.modals.processEditScreenId;
|
const sid = config.modals.processEditScreenId;
|
||||||
if (!screenId) return;
|
if (!sid) return;
|
||||||
|
window.dispatchEvent(new CustomEvent("openScreenModal", {
|
||||||
window.dispatchEvent(
|
detail: { screenId: sid, urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable }, editData: detail },
|
||||||
new CustomEvent("openScreenModal", {
|
}));
|
||||||
detail: {
|
}, [config]
|
||||||
screenId,
|
|
||||||
urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable },
|
|
||||||
editData: detail,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[config]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 기본 버전 토글
|
|
||||||
const handleToggleDefault = useCallback(
|
const handleToggleDefault = useCallback(
|
||||||
async (versionId: string, currentIsDefault: boolean) => {
|
async (versionId: string, currentIsDefault: boolean) => {
|
||||||
let success: boolean;
|
const success = currentIsDefault ? await unsetDefaultVersion(versionId) : await setDefaultVersion(versionId);
|
||||||
if (currentIsDefault) {
|
if (success) toast({ title: currentIsDefault ? "기본 버전이 해제되었습니다" : "기본 버전으로 설정되었습니다" });
|
||||||
success = await unsetDefaultVersion(versionId);
|
else toast({ title: "기본 버전 변경 실패", variant: "destructive" });
|
||||||
if (success) toast({ title: "기본 버전이 해제되었습니다" });
|
|
||||||
} else {
|
|
||||||
success = await setDefaultVersion(versionId);
|
|
||||||
if (success) toast({ title: "기본 버전으로 설정되었습니다" });
|
|
||||||
}
|
|
||||||
if (!success) {
|
|
||||||
toast({ title: "기본 버전 변경 실패", variant: "destructive" });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[setDefaultVersion, unsetDefaultVersion, toast]
|
[setDefaultVersion, unsetDefaultVersion, toast]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 삭제 확인
|
|
||||||
const handleConfirmDelete = useCallback(async () => {
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
|
const success = deleteTarget.type === "version"
|
||||||
let success = false;
|
? await deleteVersion(deleteTarget.id) : await deleteDetail(deleteTarget.id);
|
||||||
if (deleteTarget.type === "version") {
|
toast({ title: success ? `${deleteTarget.name} 삭제 완료` : "삭제 실패", variant: success ? undefined : "destructive" });
|
||||||
success = await deleteVersion(deleteTarget.id);
|
|
||||||
} else {
|
|
||||||
success = await deleteDetail(deleteTarget.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
toast({ title: `${deleteTarget.name} 삭제 완료` });
|
|
||||||
} else {
|
|
||||||
toast({ title: "삭제 실패", variant: "destructive" });
|
|
||||||
}
|
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
}, [deleteTarget, deleteVersion, deleteDetail, toast]);
|
}, [deleteTarget, deleteVersion, deleteDetail, toast]);
|
||||||
|
|
||||||
const splitRatio = config.splitRatio || 40;
|
const splitRatio = config.splitRatio || 40;
|
||||||
|
const registeredItemIds = React.useMemo(() => new Set(items.map((i) => i.id)), [items]);
|
||||||
|
|
||||||
|
// ──── 셀 값 추출 헬퍼 ────
|
||||||
|
const getCellValue = (item: Record<string, any>, colName: string) => {
|
||||||
|
return item[colName] ?? item[`item_${colName}`] ?? "-";
|
||||||
|
};
|
||||||
|
|
||||||
if (isPreview) {
|
if (isPreview) {
|
||||||
return (
|
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="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">
|
<div className="text-center">
|
||||||
<ListOrdered className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
|
<ListOrdered className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<p className="text-sm font-medium text-muted-foreground">품목별 라우팅 관리</p>
|
||||||
품목별 라우팅 관리
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||||
품목 선택 - 라우팅 버전 - 공정 순서
|
{isRegisteredMode ? "등록 품목 모드" : "전체 품목 모드"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -221,94 +207,111 @@ export function ItemRoutingComponent({
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-background">
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-background">
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* 좌측 패널: 품목 목록 */}
|
{/* ════ 좌측 패널: 품목 목록 (테이블) ════ */}
|
||||||
<div
|
<div style={{ width: `${splitRatio}%` }} className="flex shrink-0 flex-col overflow-hidden border-r">
|
||||||
style={{ width: `${splitRatio}%` }}
|
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||||
className="flex shrink-0 flex-col overflow-hidden border-r"
|
|
||||||
>
|
|
||||||
<div className="border-b px-3 py-2">
|
|
||||||
<h3 className="text-sm font-semibold">
|
<h3 className="text-sm font-semibold">
|
||||||
{config.leftPanelTitle || "품목 목록"}
|
{config.leftPanelTitle || "품목 목록"}
|
||||||
|
{isRegisteredMode && (
|
||||||
|
<span className="ml-1.5 text-[10px] font-normal text-muted-foreground">(등록 모드)</span>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
{isRegisteredMode && !config.readonly && (
|
||||||
|
<Button variant="outline" size="sm" className="h-6 gap-1 text-[10px]" onClick={handleOpenAddDialog}>
|
||||||
|
<Plus className="h-3 w-3" /> 품목 추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 */}
|
|
||||||
<div className="flex gap-1.5 border-b px-3 py-2">
|
<div className="flex gap-1.5 border-b px-3 py-2">
|
||||||
<Input
|
<Input value={searchText} onChange={(e) => setSearchText(e.target.value)}
|
||||||
value={searchText}
|
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
placeholder="품목명/품번 검색" className="h-8 text-xs" />
|
||||||
onKeyDown={handleSearchKeyDown}
|
|
||||||
placeholder="품목명/품번 검색"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" onClick={handleSearch}>
|
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" onClick={handleSearch}>
|
||||||
<Search className="h-3.5 w-3.5" />
|
<Search className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 품목 리스트 */}
|
{/* 품목 테이블 */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center p-4">
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-4">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{loading ? "로딩 중..." : "품목이 없습니다"}
|
{loading ? "로딩 중..." : isRegisteredMode ? "등록된 품목이 없습니다" : "품목이 없습니다"}
|
||||||
</p>
|
</p>
|
||||||
|
{isRegisteredMode && !loading && !config.readonly && (
|
||||||
|
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleOpenAddDialog}>
|
||||||
|
<Plus className="h-3 w-3" /> 품목 추가하기
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<Table>
|
||||||
{items.map((item) => {
|
<TableHeader>
|
||||||
const itemCode =
|
<TableRow>
|
||||||
item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number;
|
{itemDisplayCols.map((col) => (
|
||||||
const itemName =
|
<TableHead key={col.name}
|
||||||
item[config.dataSource.itemNameColumn] || item.item_name;
|
style={{ width: col.width ? `${col.width}px` : undefined }}
|
||||||
const isSelected = selectedItemCode === itemCode;
|
className={cn("text-[11px] py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
|
||||||
|
{col.label}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
{isRegisteredMode && !config.readonly && (
|
||||||
|
<TableHead className="w-[36px] py-1.5" />
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{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 (
|
return (
|
||||||
<button
|
<TableRow key={item.registered_id || item.id}
|
||||||
key={item.id}
|
className={cn("cursor-pointer group", isSelected && "bg-primary/10")}
|
||||||
className={cn(
|
onClick={() => selectItem(itemCode, itemName)}>
|
||||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted/50",
|
{itemDisplayCols.map((col) => (
|
||||||
isSelected && "bg-primary/10 font-medium"
|
<TableCell key={col.name}
|
||||||
)}
|
className={cn("text-xs py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
|
||||||
onClick={() => selectItem(itemCode, itemName)}
|
{getCellValue(item, col.name)}
|
||||||
>
|
</TableCell>
|
||||||
<Package className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
))}
|
||||||
<div className="min-w-0 flex-1">
|
{isRegisteredMode && !config.readonly && item.registered_id && (
|
||||||
<p className="truncate font-medium">{itemName}</p>
|
<TableCell className="py-1.5 text-center">
|
||||||
<p className="truncate text-muted-foreground">{itemCode}</p>
|
<Button variant="ghost" size="icon"
|
||||||
</div>
|
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
</button>
|
onClick={(e) => { e.stopPropagation(); handleUnregisterItem(item.registered_id, itemName); }}
|
||||||
);
|
title="등록 해제">
|
||||||
})}
|
<X className="h-3 w-3 text-muted-foreground hover:text-destructive" />
|
||||||
</div>
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측 패널: 버전 + 공정 */}
|
{/* ════ 우측 패널: 버전 + 공정 ════ */}
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{selectedItemCode ? (
|
{selectedItemCode ? (
|
||||||
<>
|
<>
|
||||||
{/* 헤더: 선택된 품목 + 버전 추가 */}
|
|
||||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold">{selectedItemName}</h3>
|
<h3 className="text-sm font-semibold">{selectedItemName}</h3>
|
||||||
<p className="text-xs text-muted-foreground">{selectedItemCode}</p>
|
<p className="text-xs text-muted-foreground">{selectedItemCode}</p>
|
||||||
</div>
|
</div>
|
||||||
{!config.readonly && (
|
{!config.readonly && (
|
||||||
<Button
|
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleAddVersion}>
|
||||||
variant="outline"
|
<Plus className="h-3 w-3" /> {config.versionAddButtonText || "+ 라우팅 버전 추가"}
|
||||||
size="sm"
|
|
||||||
className="h-7 gap-1 text-xs"
|
|
||||||
onClick={handleAddVersion}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
{config.versionAddButtonText || "+ 라우팅 버전 추가"}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 버전 선택 버튼들 */}
|
|
||||||
{versions.length > 0 ? (
|
{versions.length > 0 ? (
|
||||||
<div className="flex flex-wrap items-center gap-1.5 border-b px-4 py-2">
|
<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>
|
<span className="mr-1 text-xs text-muted-foreground">버전:</span>
|
||||||
|
|
@ -317,50 +320,24 @@ export function ItemRoutingComponent({
|
||||||
const isDefault = ver.is_default === true;
|
const isDefault = ver.is_default === true;
|
||||||
return (
|
return (
|
||||||
<div key={ver.id} className="flex items-center gap-0.5">
|
<div key={ver.id} className="flex items-center gap-0.5">
|
||||||
<Badge
|
<Badge variant={isActive ? "default" : "outline"}
|
||||||
variant={isActive ? "default" : "outline"}
|
className={cn("cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
|
||||||
className={cn(
|
|
||||||
"cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
|
|
||||||
isActive && "bg-primary text-primary-foreground",
|
isActive && "bg-primary text-primary-foreground",
|
||||||
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700"
|
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700")}
|
||||||
)}
|
onClick={() => selectVersion(ver.id)}>
|
||||||
onClick={() => selectVersion(ver.id)}
|
|
||||||
>
|
|
||||||
{isDefault && <Star className="mr-1 h-3 w-3 fill-current" />}
|
{isDefault && <Star className="mr-1 h-3 w-3 fill-current" />}
|
||||||
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
|
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
|
||||||
</Badge>
|
</Badge>
|
||||||
{!config.readonly && (
|
{!config.readonly && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button variant="ghost" size="icon"
|
||||||
variant="ghost"
|
className={cn("h-5 w-5", isDefault ? "text-amber-500 hover:text-amber-600" : "text-muted-foreground hover:text-amber-500")}
|
||||||
size="icon"
|
onClick={(e) => { e.stopPropagation(); handleToggleDefault(ver.id, isDefault); }}
|
||||||
className={cn(
|
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}>
|
||||||
"h-5 w-5",
|
|
||||||
isDefault
|
|
||||||
? "text-amber-500 hover:text-amber-600"
|
|
||||||
: "text-muted-foreground hover:text-amber-500"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleToggleDefault(ver.id, isDefault);
|
|
||||||
}}
|
|
||||||
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}
|
|
||||||
>
|
|
||||||
<Star className={cn("h-3 w-3", isDefault && "fill-current")} />
|
<Star className={cn("h-3 w-3", isDefault && "fill-current")} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||||
variant="ghost"
|
onClick={(e) => { e.stopPropagation(); setDeleteTarget({ type: "version", id: ver.id, name: ver.version_name || ver.id }); }}>
|
||||||
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" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|
@ -371,112 +348,65 @@ export function ItemRoutingComponent({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border-b px-4 py-3 text-center">
|
<div className="border-b px-4 py-3 text-center">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">라우팅 버전이 없습니다. 버전을 추가해주세요.</p>
|
||||||
라우팅 버전이 없습니다. 버전을 추가해주세요.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 공정 테이블 */}
|
|
||||||
{selectedVersionId ? (
|
{selectedVersionId ? (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* 공정 테이블 헤더 */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-2">
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
<h4 className="text-xs font-medium text-muted-foreground">
|
<h4 className="text-xs font-medium text-muted-foreground">
|
||||||
{config.rightPanelTitle || "공정 순서"} ({details.length}건)
|
{config.rightPanelTitle || "공정 순서"} ({details.length}건)
|
||||||
</h4>
|
</h4>
|
||||||
{!config.readonly && (
|
{!config.readonly && (
|
||||||
<Button
|
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleAddProcess}>
|
||||||
variant="outline"
|
<Plus className="h-3 w-3" /> {config.processAddButtonText || "+ 공정 추가"}
|
||||||
size="sm"
|
|
||||||
className="h-7 gap-1 text-xs"
|
|
||||||
onClick={handleAddProcess}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
{config.processAddButtonText || "+ 공정 추가"}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 */}
|
|
||||||
<div className="flex-1 overflow-auto px-4 pb-4">
|
<div className="flex-1 overflow-auto px-4 pb-4">
|
||||||
{details.length === 0 ? (
|
{details.length === 0 ? (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<div className="flex h-32 items-center justify-center">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{loading ? "로딩 중..." : "등록된 공정이 없습니다"}</p>
|
||||||
{loading ? "로딩 중..." : "등록된 공정이 없습니다"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{config.processColumns.map((col) => (
|
{config.processColumns.map((col) => (
|
||||||
<TableHead
|
<TableHead key={col.name}
|
||||||
key={col.name}
|
|
||||||
style={{ width: col.width ? `${col.width}px` : undefined }}
|
style={{ width: col.width ? `${col.width}px` : undefined }}
|
||||||
className={cn(
|
className={cn("text-xs", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
|
||||||
"text-xs",
|
|
||||||
col.align === "center" && "text-center",
|
|
||||||
col.align === "right" && "text-right"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{col.label}
|
{col.label}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
{!config.readonly && (
|
{!config.readonly && <TableHead className="w-[80px] text-center text-xs">관리</TableHead>}
|
||||||
<TableHead className="w-[80px] text-center text-xs">
|
|
||||||
관리
|
|
||||||
</TableHead>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{details.map((detail) => (
|
{details.map((detail) => (
|
||||||
<TableRow key={detail.id}>
|
<TableRow key={detail.id}>
|
||||||
{config.processColumns.map((col) => {
|
{config.processColumns.map((col) => {
|
||||||
let cellValue = detail[col.name];
|
let v = detail[col.name];
|
||||||
if (cellValue == null) {
|
if (v == null) {
|
||||||
const aliasKey = Object.keys(detail).find(
|
const ak = Object.keys(detail).find((k) => k.endsWith(`_${col.name}`));
|
||||||
(k) => k.endsWith(`_${col.name}`)
|
if (ak) v = detail[ak];
|
||||||
);
|
|
||||||
if (aliasKey) cellValue = detail[aliasKey];
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<TableCell
|
<TableCell key={col.name}
|
||||||
key={col.name}
|
className={cn("text-xs", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
|
||||||
className={cn(
|
{v ?? "-"}
|
||||||
"text-xs",
|
|
||||||
col.align === "center" && "text-center",
|
|
||||||
col.align === "right" && "text-right"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{cellValue ?? "-"}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!config.readonly && (
|
{!config.readonly && (
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleEditProcess(detail)}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => handleEditProcess(detail)}
|
|
||||||
>
|
|
||||||
<Edit className="h-3 w-3" />
|
<Edit className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive"
|
||||||
variant="ghost"
|
onClick={() => setDeleteTarget({ type: "detail", id: detail.id, name: `공정 ${detail.seq_no || detail.id}` })}>
|
||||||
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" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -492,9 +422,7 @@ export function ItemRoutingComponent({
|
||||||
) : (
|
) : (
|
||||||
versions.length > 0 && (
|
versions.length > 0 && (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">라우팅 버전을 선택해주세요</p>
|
||||||
라우팅 버전을 선택해주세요
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
@ -502,43 +430,121 @@ export function ItemRoutingComponent({
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
<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" />
|
<ListOrdered className="mb-3 h-12 w-12 text-muted-foreground/30" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<p className="text-sm font-medium text-muted-foreground">좌측에서 품목을 선택하세요</p>
|
||||||
좌측에서 품목을 선택하세요
|
<p className="mt-1 text-xs text-muted-foreground/70">품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다</p>
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
|
||||||
품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 삭제 확인 다이얼로그 */}
|
{/* ════ 삭제 확인 ════ */}
|
||||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle className="text-base">삭제 확인</AlertDialogTitle>
|
<AlertDialogTitle className="text-base">삭제 확인</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-sm">
|
<AlertDialogDescription className="text-sm">
|
||||||
{deleteTarget?.name}을(를) 삭제하시겠습니까?
|
{deleteTarget?.name}을(를) 삭제하시겠습니까?
|
||||||
{deleteTarget?.type === "version" && (
|
{deleteTarget?.type === "version" && (<><br />해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다.</>)}
|
||||||
<>
|
|
||||||
<br />
|
|
||||||
해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction onClick={handleConfirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
onClick={handleConfirmDelete}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */}
|
||||||
|
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">품목 추가</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
좌측 목록에 표시할 품목을 선택하세요
|
||||||
|
{(config.itemFilterConditions?.length ?? 0) > 0 && (
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground">
|
||||||
|
(필터 {config.itemFilterConditions!.length}건 적용됨)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Input value={addSearchText} onChange={(e) => setAddSearchText(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") fetchAllItems(addSearchText || undefined); }}
|
||||||
|
placeholder="품목명/품번 검색" className="h-8 text-xs sm:h-10 sm:text-sm" />
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0 sm:h-10 sm:w-10"
|
||||||
|
onClick={() => fetchAllItems(addSearchText || undefined)}>
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[340px] overflow-auto rounded-md border">
|
||||||
|
{allItems.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<p className="text-xs text-muted-foreground">품목이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px] text-center text-[11px] py-1.5" />
|
||||||
|
{modalDisplayCols.map((col) => (
|
||||||
|
<TableHead key={col.name}
|
||||||
|
style={{ width: col.width ? `${col.width}px` : undefined }}
|
||||||
|
className={cn("text-[11px] py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
|
||||||
|
{col.label}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
<TableHead className="w-[60px] text-center text-[11px] py-1.5">상태</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{allItems.map((item) => {
|
||||||
|
const isAlreadyRegistered = registeredItemIds.has(item.id);
|
||||||
|
const isChecked = selectedAddItems.has(item.id);
|
||||||
|
return (
|
||||||
|
<TableRow key={item.id}
|
||||||
|
className={cn("cursor-pointer", isAlreadyRegistered && "opacity-50", isChecked && "bg-primary/5")}
|
||||||
|
onClick={() => { if (!isAlreadyRegistered) handleToggleAddItem(item.id); }}>
|
||||||
|
<TableCell className="text-center py-1.5">
|
||||||
|
<Checkbox checked={isChecked || isAlreadyRegistered} disabled={isAlreadyRegistered} className="h-4 w-4" />
|
||||||
|
</TableCell>
|
||||||
|
{modalDisplayCols.map((col) => (
|
||||||
|
<TableCell key={col.name}
|
||||||
|
className={cn("text-xs py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
|
||||||
|
{getCellValue(item, col.name)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell className="text-center py-1.5">
|
||||||
|
{isAlreadyRegistered && (
|
||||||
|
<Badge variant="secondary" className="h-5 text-[10px]">등록됨</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedAddItems.size > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{selectedAddItems.size}개 선택됨</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={() => setAddDialogOpen(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</Button>
|
||||||
|
<Button onClick={handleConfirmAdd} disabled={selectedAddItems.size === 0 || addLoading}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
{addLoading ? "등록 중..." : `${selectedAddItems.size}개 추가`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
|
||||||
static componentDefinition = V2ItemRoutingDefinition;
|
static componentDefinition = V2ItemRoutingDefinition;
|
||||||
|
|
||||||
render(): React.ReactElement {
|
render(): React.ReactElement {
|
||||||
const { formData, isPreview, config, tableName } = this.props as Record<
|
const { formData, isPreview, config, tableName, screenId } = this.props as Record<
|
||||||
string,
|
string,
|
||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
|
|
@ -20,6 +20,7 @@ export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
|
||||||
formData={formData as Record<string, unknown>}
|
formData={formData as Record<string, unknown>}
|
||||||
tableName={tableName as string}
|
tableName={tableName as string}
|
||||||
isPreview={isPreview as boolean}
|
isPreview={isPreview as boolean}
|
||||||
|
screenId={screenId as number | string}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,15 @@ export const defaultConfig: ItemRoutingConfig = {
|
||||||
autoSelectFirstVersion: true,
|
autoSelectFirstVersion: true,
|
||||||
versionAddButtonText: "+ 라우팅 버전 추가",
|
versionAddButtonText: "+ 라우팅 버전 추가",
|
||||||
processAddButtonText: "+ 공정 추가",
|
processAddButtonText: "+ 공정 추가",
|
||||||
|
itemListMode: "all",
|
||||||
|
screenCode: "",
|
||||||
|
itemDisplayColumns: [
|
||||||
|
{ name: "item_name", label: "품명" },
|
||||||
|
{ name: "item_code", label: "품번", width: 100 },
|
||||||
|
],
|
||||||
|
modalDisplayColumns: [
|
||||||
|
{ name: "item_name", label: "품명" },
|
||||||
|
{ name: "item_code", label: "품번", width: 100 },
|
||||||
|
],
|
||||||
|
itemFilterConditions: [],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
import { useState, useCallback, useMemo, useRef } from "react";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData } from "../types";
|
import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData, ColumnDef } from "../types";
|
||||||
import { defaultConfig } from "../config";
|
import { defaultConfig } from "../config";
|
||||||
|
|
||||||
const API_BASE = "/process-work-standard";
|
const API_BASE = "/process-work-standard";
|
||||||
|
|
||||||
|
/** 표시 컬럼 목록에서 기본(item_name, item_code) 외 추가 컬럼만 추출 */
|
||||||
|
function getExtraColumnNames(columns?: ColumnDef[]): string {
|
||||||
|
if (!columns || columns.length === 0) return "";
|
||||||
|
return columns
|
||||||
|
.map((c) => c.name)
|
||||||
|
.filter((n) => n && n !== "item_name" && n !== "item_code")
|
||||||
|
.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
const configKey = useMemo(
|
const configKey = useMemo(
|
||||||
() => JSON.stringify(configPartial),
|
() => JSON.stringify(configPartial),
|
||||||
|
|
@ -27,21 +36,81 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
configRef.current = config;
|
configRef.current = config;
|
||||||
|
|
||||||
const [items, setItems] = useState<ItemData[]>([]);
|
const [items, setItems] = useState<ItemData[]>([]);
|
||||||
|
const [allItems, setAllItems] = useState<ItemData[]>([]);
|
||||||
const [versions, setVersions] = useState<RoutingVersionData[]>([]);
|
const [versions, setVersions] = useState<RoutingVersionData[]>([]);
|
||||||
const [details, setDetails] = useState<RoutingDetailData[]>([]);
|
const [details, setDetails] = useState<RoutingDetailData[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// 선택 상태
|
|
||||||
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
||||||
const [selectedItemName, setSelectedItemName] = useState<string | null>(null);
|
const [selectedItemName, setSelectedItemName] = useState<string | null>(null);
|
||||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 품목 목록 조회
|
const isRegisteredMode = config.itemListMode === "registered";
|
||||||
|
|
||||||
|
/** API 기본 파라미터 생성 */
|
||||||
|
const buildBaseParams = useCallback((search?: string, columns?: ColumnDef[]) => {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const extra = getExtraColumnNames(columns);
|
||||||
|
const filters = configRef.current.itemFilterConditions;
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
tableName: ds.itemTable,
|
||||||
|
nameColumn: ds.itemNameColumn,
|
||||||
|
codeColumn: ds.itemCodeColumn,
|
||||||
|
routingTable: ds.routingVersionTable,
|
||||||
|
routingFkColumn: ds.routingVersionFkColumn,
|
||||||
|
};
|
||||||
|
if (search) params.search = search;
|
||||||
|
if (extra) params.extraColumns = extra;
|
||||||
|
if (filters && filters.length > 0) {
|
||||||
|
params.filterConditions = JSON.stringify(filters);
|
||||||
|
}
|
||||||
|
return new URLSearchParams(params);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ────────────────────────────────────────
|
||||||
|
// 품목 목록 조회 (all 모드)
|
||||||
|
// ────────────────────────────────────────
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async (search?: string) => {
|
async (search?: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const cols = configRef.current.itemDisplayColumns;
|
||||||
|
const params = buildBaseParams(search, cols);
|
||||||
|
const res = await apiClient.get(`${API_BASE}/items?${params}`);
|
||||||
|
if (res.data?.success) {
|
||||||
|
const data = res.data.data || [];
|
||||||
|
if (configRef.current.itemListMode !== "registered") {
|
||||||
|
setItems(data);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("품목 조회 실패", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[configKey, buildBaseParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ────────────────────────────────────────
|
||||||
|
// 등록 품목 조회 (registered 모드)
|
||||||
|
// ────────────────────────────────────────
|
||||||
|
const fetchRegisteredItems = useCallback(
|
||||||
|
async (search?: string) => {
|
||||||
|
const screenCode = configRef.current.screenCode;
|
||||||
|
if (!screenCode) {
|
||||||
|
console.warn("screenCode가 설정되지 않았습니다");
|
||||||
|
setItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const ds = configRef.current.dataSource;
|
const ds = configRef.current.dataSource;
|
||||||
|
const cols = configRef.current.itemDisplayColumns;
|
||||||
|
const extra = getExtraColumnNames(cols);
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
tableName: ds.itemTable,
|
tableName: ds.itemTable,
|
||||||
nameColumn: ds.itemNameColumn,
|
nameColumn: ds.itemNameColumn,
|
||||||
|
|
@ -49,13 +118,16 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
routingTable: ds.routingVersionTable,
|
routingTable: ds.routingVersionTable,
|
||||||
routingFkColumn: ds.routingVersionFkColumn,
|
routingFkColumn: ds.routingVersionFkColumn,
|
||||||
...(search ? { search } : {}),
|
...(search ? { search } : {}),
|
||||||
|
...(extra ? { extraColumns: extra } : {}),
|
||||||
});
|
});
|
||||||
const res = await apiClient.get(`${API_BASE}/items?${params}`);
|
const res = await apiClient.get(
|
||||||
|
`${API_BASE}/registered-items/${encodeURIComponent(screenCode)}?${params}`
|
||||||
|
);
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
setItems(res.data.data || []);
|
setItems(res.data.data || []);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("품목 조회 실패", err);
|
console.error("등록 품목 조회 실패", err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +136,104 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
[configKey]
|
[configKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 라우팅 버전 목록 조회
|
// ────────────────────────────────────────
|
||||||
|
// 전체 품목 조회 (등록 팝업용 - 필터+추가컬럼 적용)
|
||||||
|
// ────────────────────────────────────────
|
||||||
|
const fetchAllItems = useCallback(
|
||||||
|
async (search?: string) => {
|
||||||
|
try {
|
||||||
|
const cols = configRef.current.modalDisplayColumns;
|
||||||
|
const params = buildBaseParams(search, cols);
|
||||||
|
const res = await apiClient.get(`${API_BASE}/items?${params}`);
|
||||||
|
if (res.data?.success) {
|
||||||
|
setAllItems(res.data.data || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("전체 품목 조회 실패", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[configKey, buildBaseParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ────────────────────────────────────────
|
||||||
|
// 품목 등록/제거 (registered 모드)
|
||||||
|
// ────────────────────────────────────────
|
||||||
|
const registerItem = useCallback(
|
||||||
|
async (itemId: string, itemCode: string) => {
|
||||||
|
const screenCode = configRef.current.screenCode;
|
||||||
|
if (!screenCode) return false;
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`${API_BASE}/registered-items`, {
|
||||||
|
screenCode,
|
||||||
|
itemId,
|
||||||
|
itemCode,
|
||||||
|
});
|
||||||
|
if (res.data?.success) {
|
||||||
|
await fetchRegisteredItems();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("품목 등록 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[fetchRegisteredItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const registerItemsBatch = useCallback(
|
||||||
|
async (itemList: { itemId: string; itemCode: string }[]) => {
|
||||||
|
const screenCode = configRef.current.screenCode;
|
||||||
|
if (!screenCode) return false;
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`${API_BASE}/registered-items/batch`, {
|
||||||
|
screenCode,
|
||||||
|
items: itemList,
|
||||||
|
});
|
||||||
|
if (res.data?.success) {
|
||||||
|
await fetchRegisteredItems();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("품목 일괄 등록 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[fetchRegisteredItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unregisterItem = useCallback(
|
||||||
|
async (registeredId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.delete(`${API_BASE}/registered-items/${registeredId}`);
|
||||||
|
if (res.data?.success) {
|
||||||
|
if (selectedItemCode) {
|
||||||
|
const removedItem = items.find((i) => i.registered_id === registeredId);
|
||||||
|
if (removedItem) {
|
||||||
|
const removedCode = removedItem.item_code || removedItem[configRef.current.dataSource.itemCodeColumn];
|
||||||
|
if (selectedItemCode === removedCode) {
|
||||||
|
setSelectedItemCode(null);
|
||||||
|
setSelectedItemName(null);
|
||||||
|
setSelectedVersionId(null);
|
||||||
|
setVersions([]);
|
||||||
|
setDetails([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fetchRegisteredItems();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("등록 품목 제거 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[selectedItemCode, items, fetchRegisteredItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ────────────────────────────────────────
|
||||||
|
// 라우팅 버전/공정 관련 (기존 동일)
|
||||||
|
// ────────────────────────────────────────
|
||||||
const fetchVersions = useCallback(
|
const fetchVersions = useCallback(
|
||||||
async (itemCode: string) => {
|
async (itemCode: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -94,7 +263,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
[configKey]
|
[configKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함
|
|
||||||
const fetchDetails = useCallback(
|
const fetchDetails = useCallback(
|
||||||
async (versionId: string) => {
|
async (versionId: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -128,18 +296,14 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
[configKey]
|
[configKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 품목 선택
|
|
||||||
const selectItem = useCallback(
|
const selectItem = useCallback(
|
||||||
async (itemCode: string, itemName: string) => {
|
async (itemCode: string, itemName: string) => {
|
||||||
setSelectedItemCode(itemCode);
|
setSelectedItemCode(itemCode);
|
||||||
setSelectedItemName(itemName);
|
setSelectedItemName(itemName);
|
||||||
setSelectedVersionId(null);
|
setSelectedVersionId(null);
|
||||||
setDetails([]);
|
setDetails([]);
|
||||||
|
|
||||||
const versionList = await fetchVersions(itemCode);
|
const versionList = await fetchVersions(itemCode);
|
||||||
|
|
||||||
if (versionList.length > 0) {
|
if (versionList.length > 0) {
|
||||||
// 기본 버전 우선, 없으면 첫번째 버전 선택
|
|
||||||
const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default);
|
const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default);
|
||||||
const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null);
|
const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null);
|
||||||
if (targetVersion) {
|
if (targetVersion) {
|
||||||
|
|
@ -151,7 +315,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
[fetchVersions, fetchDetails]
|
[fetchVersions, fetchDetails]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 버전 선택
|
|
||||||
const selectVersion = useCallback(
|
const selectVersion = useCallback(
|
||||||
async (versionId: string) => {
|
async (versionId: string) => {
|
||||||
setSelectedVersionId(versionId);
|
setSelectedVersionId(versionId);
|
||||||
|
|
@ -160,7 +323,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
[fetchDetails]
|
[fetchDetails]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 모달에서 데이터 변경 후 새로고침
|
|
||||||
const refreshVersions = useCallback(async () => {
|
const refreshVersions = useCallback(async () => {
|
||||||
if (selectedItemCode) {
|
if (selectedItemCode) {
|
||||||
const versionList = await fetchVersions(selectedItemCode);
|
const versionList = await fetchVersions(selectedItemCode);
|
||||||
|
|
@ -180,7 +342,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
}
|
}
|
||||||
}, [selectedVersionId, fetchDetails]);
|
}, [selectedVersionId, fetchDetails]);
|
||||||
|
|
||||||
// 공정 삭제
|
|
||||||
const deleteDetail = useCallback(
|
const deleteDetail = useCallback(
|
||||||
async (detailId: string) => {
|
async (detailId: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -189,19 +350,13 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
`/table-management/tables/${ds.routingDetailTable}/delete`,
|
`/table-management/tables/${ds.routingDetailTable}/delete`,
|
||||||
{ data: [{ id: detailId }] }
|
{ data: [{ id: detailId }] }
|
||||||
);
|
);
|
||||||
if (res.data?.success) {
|
if (res.data?.success) { await refreshDetails(); return true; }
|
||||||
await refreshDetails();
|
} catch (err) { console.error("공정 삭제 실패", err); }
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("공정 삭제 실패", err);
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[refreshDetails]
|
[refreshDetails]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 버전 삭제
|
|
||||||
const deleteVersion = useCallback(
|
const deleteVersion = useCallback(
|
||||||
async (versionId: string) => {
|
async (versionId: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -211,22 +366,16 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
{ data: [{ id: versionId }] }
|
{ data: [{ id: versionId }] }
|
||||||
);
|
);
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
if (selectedVersionId === versionId) {
|
if (selectedVersionId === versionId) { setSelectedVersionId(null); setDetails([]); }
|
||||||
setSelectedVersionId(null);
|
|
||||||
setDetails([]);
|
|
||||||
}
|
|
||||||
await refreshVersions();
|
await refreshVersions();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) { console.error("버전 삭제 실패", err); }
|
||||||
console.error("버전 삭제 실패", err);
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[selectedVersionId, refreshVersions]
|
[selectedVersionId, refreshVersions]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 기본 버전 설정
|
|
||||||
const setDefaultVersion = useCallback(
|
const setDefaultVersion = useCallback(
|
||||||
async (versionId: string) => {
|
async (versionId: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -236,20 +385,15 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
routingFkColumn: ds.routingVersionFkColumn,
|
routingFkColumn: ds.routingVersionFkColumn,
|
||||||
});
|
});
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
if (selectedItemCode) {
|
if (selectedItemCode) await fetchVersions(selectedItemCode);
|
||||||
await fetchVersions(selectedItemCode);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) { console.error("기본 버전 설정 실패", err); }
|
||||||
console.error("기본 버전 설정 실패", err);
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[selectedItemCode, fetchVersions]
|
[selectedItemCode, fetchVersions]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 기본 버전 해제
|
|
||||||
const unsetDefaultVersion = useCallback(
|
const unsetDefaultVersion = useCallback(
|
||||||
async (versionId: string) => {
|
async (versionId: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -258,14 +402,10 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
routingVersionTable: ds.routingVersionTable,
|
routingVersionTable: ds.routingVersionTable,
|
||||||
});
|
});
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
if (selectedItemCode) {
|
if (selectedItemCode) await fetchVersions(selectedItemCode);
|
||||||
await fetchVersions(selectedItemCode);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) { console.error("기본 버전 해제 실패", err); }
|
||||||
console.error("기본 버전 해제 실패", err);
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
[selectedItemCode, fetchVersions]
|
[selectedItemCode, fetchVersions]
|
||||||
|
|
@ -274,13 +414,20 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
items,
|
items,
|
||||||
|
allItems,
|
||||||
versions,
|
versions,
|
||||||
details,
|
details,
|
||||||
loading,
|
loading,
|
||||||
selectedItemCode,
|
selectedItemCode,
|
||||||
selectedItemName,
|
selectedItemName,
|
||||||
selectedVersionId,
|
selectedVersionId,
|
||||||
|
isRegisteredMode,
|
||||||
fetchItems,
|
fetchItems,
|
||||||
|
fetchRegisteredItems,
|
||||||
|
fetchAllItems,
|
||||||
|
registerItem,
|
||||||
|
registerItemsBatch,
|
||||||
|
unregisterItem,
|
||||||
selectItem,
|
selectItem,
|
||||||
selectVersion,
|
selectVersion,
|
||||||
refreshVersions,
|
refreshVersions,
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ export interface ItemRoutingDataSource {
|
||||||
itemNameColumn: string;
|
itemNameColumn: string;
|
||||||
itemCodeColumn: string;
|
itemCodeColumn: string;
|
||||||
routingVersionTable: string;
|
routingVersionTable: string;
|
||||||
routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK
|
routingVersionFkColumn: string;
|
||||||
routingVersionNameColumn: string;
|
routingVersionNameColumn: string;
|
||||||
routingDetailTable: string;
|
routingDetailTable: string;
|
||||||
routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK
|
routingDetailFkColumn: string;
|
||||||
processTable: string;
|
processTable: string;
|
||||||
processNameColumn: string;
|
processNameColumn: string;
|
||||||
processCodeColumn: string;
|
processCodeColumn: string;
|
||||||
|
|
@ -26,14 +26,24 @@ export interface ItemRoutingModals {
|
||||||
processEditScreenId?: number;
|
processEditScreenId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 공정 테이블 컬럼 정의
|
// 컬럼 정의 (공정/품목 공용)
|
||||||
export interface ProcessColumnDef {
|
export interface ColumnDef {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
align?: "left" | "center" | "right";
|
align?: "left" | "center" | "right";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 공정 테이블 컬럼 정의 (기존 호환)
|
||||||
|
export type ProcessColumnDef = ColumnDef;
|
||||||
|
|
||||||
|
// 품목 필터 조건
|
||||||
|
export interface ItemFilterCondition {
|
||||||
|
column: string;
|
||||||
|
operator: "equals" | "contains" | "not_equals";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 전체 Config
|
// 전체 Config
|
||||||
export interface ItemRoutingConfig {
|
export interface ItemRoutingConfig {
|
||||||
dataSource: ItemRoutingDataSource;
|
dataSource: ItemRoutingDataSource;
|
||||||
|
|
@ -46,6 +56,14 @@ export interface ItemRoutingConfig {
|
||||||
autoSelectFirstVersion?: boolean;
|
autoSelectFirstVersion?: boolean;
|
||||||
versionAddButtonText?: string;
|
versionAddButtonText?: string;
|
||||||
processAddButtonText?: string;
|
processAddButtonText?: string;
|
||||||
|
itemListMode?: "all" | "registered";
|
||||||
|
screenCode?: string;
|
||||||
|
/** 좌측 품목 목록에 표시할 컬럼 */
|
||||||
|
itemDisplayColumns?: ColumnDef[];
|
||||||
|
/** 품목 추가 모달에 표시할 컬럼 */
|
||||||
|
modalDisplayColumns?: ColumnDef[];
|
||||||
|
/** 품목 조회 시 사전 필터 조건 */
|
||||||
|
itemFilterConditions?: ItemFilterCondition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컴포넌트 Props
|
// 컴포넌트 Props
|
||||||
|
|
@ -54,6 +72,7 @@ export interface ItemRoutingComponentProps {
|
||||||
formData?: Record<string, any>;
|
formData?: Record<string, any>;
|
||||||
isPreview?: boolean;
|
isPreview?: boolean;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
|
screenId?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 모델
|
// 데이터 모델
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue