feat: implement registered items management in process work standard
- Added new endpoints for managing registered items, including retrieval, registration, and batch registration. - Enhanced the existing processWorkStandardController to support filtering and additional columns in item queries. - Updated the processWorkStandardRoutes to include routes for registered items management. - Introduced a new documentation file detailing the design and structure of the POP 작업진행 관리 system. These changes aim to improve the management of registered items within the process work standard, enhancing usability and functionality. Made-with: Cursor
This commit is contained in:
parent
772a10258c
commit
3df9a39ebe
|
|
@ -30,26 +30,68 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
|
|||
routingTable = "item_routing_version",
|
||||
routingFkColumn = "item_code",
|
||||
search = "",
|
||||
extraColumns = "",
|
||||
filterConditions = "",
|
||||
} = req.query as Record<string, string>;
|
||||
|
||||
const searchCondition = search
|
||||
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
|
||||
: "";
|
||||
const params: any[] = [companyCode];
|
||||
if (search) params.push(`%${search}%`);
|
||||
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 = `
|
||||
SELECT
|
||||
i.id,
|
||||
i.${nameColumn} AS item_name,
|
||||
i.${codeColumn} AS item_code,
|
||||
i.${codeColumn} AS item_code
|
||||
${extraSelect ? ", " + extraSelect : ""},
|
||||
COUNT(rv.id) AS routing_count
|
||||
FROM ${tableName} i
|
||||
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||
AND rv.company_code = i.company_code
|
||||
WHERE i.company_code = $1
|
||||
${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
|
||||
`;
|
||||
|
||||
|
|
@ -711,3 +753,184 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
|||
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.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;
|
||||
|
|
|
|||
|
|
@ -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 품목별 라우팅 설정 패널
|
||||
* 토스식 단계별 UX: 데이터 소스 -> 모달 연동 -> 공정 컬럼 -> 레이아웃(접힘)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
|
@ -16,10 +15,10 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Settings, ChevronDown, ChevronRight, Plus, Trash2, Check, ChevronsUpDown,
|
||||
Database, Monitor, Columns,
|
||||
Database, Monitor, Columns, List, Filter, Eye,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
|
||||
interface V2ItemRoutingConfigPanelProps {
|
||||
|
|
@ -27,53 +26,21 @@ interface V2ItemRoutingConfigPanelProps {
|
|||
onChange: (config: Partial<ItemRoutingConfig>) => void;
|
||||
}
|
||||
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
displayName?: string;
|
||||
}
|
||||
interface TableInfo { tableName: string; displayName?: string; }
|
||||
interface ColumnInfo { columnName: string; displayName?: string; dataType?: string; }
|
||||
interface ScreenInfo { screenId: number; screenName: string; screenCode: string; }
|
||||
|
||||
interface ColumnInfo {
|
||||
columnName: string;
|
||||
displayName?: string;
|
||||
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;
|
||||
// ─── 공용: 테이블 Combobox ───
|
||||
function TableCombobox({ value, onChange, tables, loading }: {
|
||||
value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = tables.find((t) => t.tableName === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading
|
||||
? "로딩 중..."
|
||||
: selected
|
||||
? selected.displayName || selected.tableName
|
||||
: "테이블 선택"}
|
||||
<Button variant="outline" 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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -84,12 +51,8 @@ function TableCombobox({
|
|||
<CommandEmpty className="py-4 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{tables.map((t) => (
|
||||
<CommandItem
|
||||
key={t.tableName}
|
||||
value={`${t.displayName || ""} ${t.tableName}`}
|
||||
onSelect={() => { onChange(t.tableName); setOpen(false); }}
|
||||
className="text-xs"
|
||||
>
|
||||
<CommandItem key={t.tableName} value={`${t.displayName || ""} ${t.tableName}`}
|
||||
onSelect={() => { onChange(t.tableName); setOpen(false); }} className="text-xs">
|
||||
<Check className={cn("mr-2 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||
|
|
@ -105,17 +68,9 @@ function TableCombobox({
|
|||
);
|
||||
}
|
||||
|
||||
// ─── 컬럼 Combobox ───
|
||||
function ColumnCombobox({
|
||||
value,
|
||||
onChange,
|
||||
tableName,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
tableName: string;
|
||||
placeholder?: string;
|
||||
// ─── 공용: 컬럼 Combobox ───
|
||||
function ColumnCombobox({ value, onChange, tableName, placeholder }: {
|
||||
value: string; onChange: (v: string, displayName?: string) => void; tableName: string; placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
|
|
@ -128,26 +83,17 @@ function ColumnCombobox({
|
|||
try {
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const res = await tableManagementApi.getColumnList(tableName);
|
||||
if (res.success && res.data?.columns) {
|
||||
setColumns(res.data.columns);
|
||||
}
|
||||
if (res.success && res.data?.columns) setColumns(res.data.columns);
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
};
|
||||
load();
|
||||
}, [tableName]);
|
||||
|
||||
const selected = columns.find((c) => c.columnName === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
disabled={loading || !tableName}
|
||||
>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading || !tableName}>
|
||||
<span className="truncate">
|
||||
{loading ? "로딩..." : !tableName ? "테이블 먼저 선택" : selected ? selected.displayName || selected.columnName : placeholder || "컬럼 선택"}
|
||||
</span>
|
||||
|
|
@ -161,12 +107,8 @@ function ColumnCombobox({
|
|||
<CommandEmpty className="py-4 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{columns.map((c) => (
|
||||
<CommandItem
|
||||
key={c.columnName}
|
||||
value={`${c.displayName || ""} ${c.columnName}`}
|
||||
onSelect={() => { onChange(c.columnName); setOpen(false); }}
|
||||
className="text-xs"
|
||||
>
|
||||
<CommandItem key={c.columnName} value={`${c.displayName || ""} ${c.columnName}`}
|
||||
onSelect={() => { onChange(c.columnName, c.displayName); setOpen(false); }} className="text-xs">
|
||||
<Check className={cn("mr-2 h-3 w-3", value === c.columnName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{c.displayName || c.columnName}</span>
|
||||
|
|
@ -182,14 +124,8 @@ function ColumnCombobox({
|
|||
);
|
||||
}
|
||||
|
||||
// ─── 화면 Combobox ───
|
||||
function ScreenCombobox({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: number;
|
||||
onChange: (v?: number) => void;
|
||||
}) {
|
||||
// ─── 공용: 화면 Combobox ───
|
||||
function ScreenCombobox({ value, onChange }: { value?: number; onChange: (v?: number) => void; }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -201,13 +137,9 @@ function ScreenCombobox({
|
|||
const { screenApi } = await import("@/lib/api/screen");
|
||||
const res = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||
if (res.data) {
|
||||
setScreens(
|
||||
res.data.map((s: any) => ({
|
||||
screenId: s.screenId,
|
||||
screenName: s.screenName || `화면 ${s.screenId}`,
|
||||
screenCode: s.screenCode || "",
|
||||
}))
|
||||
);
|
||||
setScreens(res.data.map((s: any) => ({
|
||||
screenId: s.screenId, screenName: s.screenName || `화면 ${s.screenId}`, screenCode: s.screenCode || "",
|
||||
})));
|
||||
}
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
};
|
||||
|
|
@ -215,20 +147,11 @@ function ScreenCombobox({
|
|||
}, []);
|
||||
|
||||
const selected = screens.find((s) => s.screenId === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
<span className="truncate">
|
||||
{loading ? "로딩..." : selected ? selected.screenName : "화면 선택"}
|
||||
</span>
|
||||
<Button variant="outline" 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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -239,12 +162,8 @@ function ScreenCombobox({
|
|||
<CommandEmpty className="py-4 text-center text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{screens.map((s) => (
|
||||
<CommandItem
|
||||
key={s.screenId}
|
||||
value={`${s.screenName} ${s.screenCode} ${s.screenId}`}
|
||||
onSelect={() => { onChange(s.screenId); setOpen(false); }}
|
||||
className="text-xs"
|
||||
>
|
||||
<CommandItem key={s.screenId} 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")} />
|
||||
<div className="flex flex-col">
|
||||
<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> = ({
|
||||
config: configProp,
|
||||
onChange,
|
||||
}) => {
|
||||
export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> = ({ config: configProp, onChange }) => {
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [columnsOpen, setColumnsOpen] = useState(false);
|
||||
const [dataSourceOpen, setDataSourceOpen] = useState(false);
|
||||
const [layoutOpen, setLayoutOpen] = useState(false);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
|
||||
const config: ItemRoutingConfig = {
|
||||
...defaultConfig,
|
||||
|
|
@ -278,6 +284,9 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
|||
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
||||
modals: { ...defaultConfig.modals, ...configProp?.modals },
|
||||
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(() => {
|
||||
|
|
@ -287,12 +296,7 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
|||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const res = await tableManagementApi.getTableList();
|
||||
if (res.success && res.data) {
|
||||
setTables(
|
||||
res.data.map((t: any) => ({
|
||||
tableName: t.tableName,
|
||||
displayName: t.displayName || t.tableName,
|
||||
}))
|
||||
);
|
||||
setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
|
||||
}
|
||||
} catch { /* ignore */ } finally { setLoadingTables(false); }
|
||||
};
|
||||
|
|
@ -301,11 +305,7 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
|||
|
||||
const dispatchConfigEvent = (newConfig: Partial<ItemRoutingConfig>) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", {
|
||||
detail: { config: { ...config, ...newConfig } },
|
||||
})
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("componentConfigChanged", { detail: { config: { ...config, ...newConfig } } }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -316,61 +316,141 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
|||
};
|
||||
|
||||
const updateDataSource = (field: string, value: string) => {
|
||||
const newDataSource = { ...config.dataSource, [field]: value };
|
||||
const partial = { dataSource: newDataSource };
|
||||
onChange({ ...configProp, ...partial });
|
||||
dispatchConfigEvent(partial);
|
||||
const newDS = { ...config.dataSource, [field]: value };
|
||||
onChange({ ...configProp, dataSource: newDS });
|
||||
dispatchConfigEvent({ dataSource: newDS });
|
||||
};
|
||||
|
||||
const updateModals = (field: string, value?: number) => {
|
||||
const newModals = { ...config.modals, [field]: value };
|
||||
const partial = { modals: newModals };
|
||||
onChange({ ...configProp, ...partial });
|
||||
dispatchConfigEvent(partial);
|
||||
const newM = { ...config.modals, [field]: value };
|
||||
onChange({ ...configProp, modals: newM });
|
||||
dispatchConfigEvent({ modals: newM });
|
||||
};
|
||||
|
||||
// 공정 컬럼 관리
|
||||
const addColumn = () => {
|
||||
update({
|
||||
processColumns: [
|
||||
...config.processColumns,
|
||||
{ name: "", label: "새 컬럼", width: 100, align: "left" as const },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
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 });
|
||||
// 필터 조건 관리
|
||||
const filters = config.itemFilterConditions || [];
|
||||
const addFilter = () => update({ itemFilterConditions: [...filters, { column: "", operator: "equals", value: "" }] });
|
||||
const removeFilter = (idx: number) => update({ itemFilterConditions: filters.filter((_, i) => i !== idx) });
|
||||
const updateFilter = (idx: number, field: keyof ItemFilterCondition, val: string) => {
|
||||
const next = [...filters];
|
||||
next[idx] = { ...next[idx], [field]: val };
|
||||
update({ itemFilterConditions: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">모달 연동</span>
|
||||
<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>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||||
modalOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", modalOpen && "rotate-180")} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
|
|
@ -379,291 +459,103 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
|||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">버전 추가</span>
|
||||
<ScreenCombobox
|
||||
value={config.modals.versionAddScreenId}
|
||||
onChange={(v) => updateModals("versionAddScreenId", v)}
|
||||
/>
|
||||
<ScreenCombobox value={config.modals.versionAddScreenId} onChange={(v) => updateModals("versionAddScreenId", v)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">공정 추가</span>
|
||||
<ScreenCombobox
|
||||
value={config.modals.processAddScreenId}
|
||||
onChange={(v) => updateModals("processAddScreenId", v)}
|
||||
/>
|
||||
<ScreenCombobox value={config.modals.processAddScreenId} onChange={(v) => updateModals("processAddScreenId", v)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">공정 수정</span>
|
||||
<ScreenCombobox
|
||||
value={config.modals.processEditScreenId}
|
||||
onChange={(v) => updateModals("processEditScreenId", v)}
|
||||
/>
|
||||
<ScreenCombobox value={config.modals.processEditScreenId} onChange={(v) => updateModals("processEditScreenId", v)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* ─── 2단계: 공정 테이블 컬럼 (Collapsible + 접이식 카드) ─── */}
|
||||
<Collapsible open={columnsOpen} onOpenChange={setColumnsOpen}>
|
||||
<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">
|
||||
<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>
|
||||
{/* ─── 공정 테이블 컬럼 ─── */}
|
||||
<ColumnEditor
|
||||
columns={config.processColumns}
|
||||
onChange={(cols) => update({ processColumns: cols })}
|
||||
tableName={config.dataSource.routingDetailTable}
|
||||
title="공정 테이블 컬럼"
|
||||
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
|
||||
{/* ─── 3단계: 데이터 소스 (Collapsible) ─── */}
|
||||
{/* ─── 데이터 소스 ─── */}
|
||||
<Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">데이터 소스 설정</span>
|
||||
{config.dataSource.itemTable && (
|
||||
<Badge variant="secondary" className="text-[10px] h-5 truncate max-w-[100px]">
|
||||
{config.dataSource.itemTable}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-[10px] h-5 truncate max-w-[100px]">{config.dataSource.itemTable}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||||
dataSourceOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", dataSourceOpen && "rotate-180")} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">품목 테이블</span>
|
||||
<TableCombobox
|
||||
value={config.dataSource.itemTable}
|
||||
onChange={(v) => updateDataSource("itemTable", v)}
|
||||
tables={tables}
|
||||
loading={loadingTables}
|
||||
/>
|
||||
<TableCombobox value={config.dataSource.itemTable} onChange={(v) => updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">품목명 컬럼</span>
|
||||
<ColumnCombobox
|
||||
value={config.dataSource.itemNameColumn}
|
||||
onChange={(v) => updateDataSource("itemNameColumn", v)}
|
||||
tableName={config.dataSource.itemTable}
|
||||
placeholder="품목명"
|
||||
/>
|
||||
<ColumnCombobox value={config.dataSource.itemNameColumn} onChange={(v) => updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">품목코드 컬럼</span>
|
||||
<ColumnCombobox
|
||||
value={config.dataSource.itemCodeColumn}
|
||||
onChange={(v) => updateDataSource("itemCodeColumn", v)}
|
||||
tableName={config.dataSource.itemTable}
|
||||
placeholder="품목코드"
|
||||
/>
|
||||
<ColumnCombobox value={config.dataSource.itemCodeColumn} onChange={(v) => updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
|
||||
</div>
|
||||
<div className="space-y-1 pt-2">
|
||||
<span className="text-xs text-muted-foreground">라우팅 버전 테이블</span>
|
||||
<TableCombobox
|
||||
value={config.dataSource.routingVersionTable}
|
||||
onChange={(v) => updateDataSource("routingVersionTable", v)}
|
||||
tables={tables}
|
||||
loading={loadingTables}
|
||||
/>
|
||||
<TableCombobox value={config.dataSource.routingVersionTable} onChange={(v) => updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">품목 FK 컬럼</span>
|
||||
<ColumnCombobox
|
||||
value={config.dataSource.routingVersionFkColumn}
|
||||
onChange={(v) => updateDataSource("routingVersionFkColumn", v)}
|
||||
tableName={config.dataSource.routingVersionTable}
|
||||
placeholder="FK 컬럼"
|
||||
/>
|
||||
<ColumnCombobox value={config.dataSource.routingVersionFkColumn} onChange={(v) => updateDataSource("routingVersionFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">버전명 컬럼</span>
|
||||
<ColumnCombobox
|
||||
value={config.dataSource.routingVersionNameColumn}
|
||||
onChange={(v) => updateDataSource("routingVersionNameColumn", v)}
|
||||
tableName={config.dataSource.routingVersionTable}
|
||||
placeholder="버전명"
|
||||
/>
|
||||
<ColumnCombobox value={config.dataSource.routingVersionNameColumn} onChange={(v) => updateDataSource("routingVersionNameColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="버전명" />
|
||||
</div>
|
||||
<div className="space-y-1 pt-2">
|
||||
<span className="text-xs text-muted-foreground">라우팅 상세 테이블</span>
|
||||
<TableCombobox
|
||||
value={config.dataSource.routingDetailTable}
|
||||
onChange={(v) => updateDataSource("routingDetailTable", v)}
|
||||
tables={tables}
|
||||
loading={loadingTables}
|
||||
/>
|
||||
<TableCombobox value={config.dataSource.routingDetailTable} onChange={(v) => updateDataSource("routingDetailTable", v)} tables={tables} loading={loadingTables} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">버전 FK 컬럼</span>
|
||||
<ColumnCombobox
|
||||
value={config.dataSource.routingDetailFkColumn}
|
||||
onChange={(v) => updateDataSource("routingDetailFkColumn", v)}
|
||||
tableName={config.dataSource.routingDetailTable}
|
||||
placeholder="FK 컬럼"
|
||||
/>
|
||||
<ColumnCombobox value={config.dataSource.routingDetailFkColumn} onChange={(v) => updateDataSource("routingDetailFkColumn", v)} tableName={config.dataSource.routingDetailTable} placeholder="FK 컬럼" />
|
||||
</div>
|
||||
<div className="space-y-1 pt-2">
|
||||
<span className="text-xs text-muted-foreground">공정 마스터 테이블</span>
|
||||
<TableCombobox
|
||||
value={config.dataSource.processTable}
|
||||
onChange={(v) => updateDataSource("processTable", v)}
|
||||
tables={tables}
|
||||
loading={loadingTables}
|
||||
/>
|
||||
<TableCombobox value={config.dataSource.processTable} onChange={(v) => updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">공정명 컬럼</span>
|
||||
<ColumnCombobox
|
||||
value={config.dataSource.processNameColumn}
|
||||
onChange={(v) => updateDataSource("processNameColumn", v)}
|
||||
tableName={config.dataSource.processTable}
|
||||
placeholder="공정명"
|
||||
/>
|
||||
<ColumnCombobox value={config.dataSource.processNameColumn} onChange={(v) => updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">공정코드 컬럼</span>
|
||||
<ColumnCombobox
|
||||
value={config.dataSource.processCodeColumn}
|
||||
onChange={(v) => updateDataSource("processCodeColumn", v)}
|
||||
tableName={config.dataSource.processTable}
|
||||
placeholder="공정코드"
|
||||
/>
|
||||
<ColumnCombobox value={config.dataSource.processCodeColumn} onChange={(v) => updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* ─── 4단계: 레이아웃 & 기타 (Collapsible) ─── */}
|
||||
{/* ─── 레이아웃 & 기타 ─── */}
|
||||
<Collapsible open={layoutOpen} onOpenChange={setLayoutOpen}>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">레이아웃 & 기타</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||||
layoutOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", layoutOpen && "rotate-180")} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
|
|
@ -673,76 +565,38 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
|||
<span className="text-xs text-muted-foreground">좌측 패널 비율 (%)</span>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">품목 목록 패널의 너비</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={20}
|
||||
max={60}
|
||||
value={config.splitRatio || 40}
|
||||
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 40 })}
|
||||
className="h-7 w-[80px] text-xs"
|
||||
/>
|
||||
<Input type="number" 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 className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">좌측 패널 제목</span>
|
||||
<Input
|
||||
value={config.leftPanelTitle || ""}
|
||||
onChange={(e) => update({ leftPanelTitle: e.target.value })}
|
||||
placeholder="품목 목록"
|
||||
className="h-7 w-[140px] text-xs"
|
||||
/>
|
||||
<Input value={config.leftPanelTitle || ""} onChange={(e) => update({ leftPanelTitle: e.target.value })} placeholder="품목 목록" className="h-7 w-[140px] text-xs" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">우측 패널 제목</span>
|
||||
<Input
|
||||
value={config.rightPanelTitle || ""}
|
||||
onChange={(e) => update({ rightPanelTitle: e.target.value })}
|
||||
placeholder="공정 순서"
|
||||
className="h-7 w-[140px] text-xs"
|
||||
/>
|
||||
<Input value={config.rightPanelTitle || ""} onChange={(e) => update({ rightPanelTitle: e.target.value })} placeholder="공정 순서" className="h-7 w-[140px] text-xs" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">버전 추가 버튼 텍스트</span>
|
||||
<Input
|
||||
value={config.versionAddButtonText || ""}
|
||||
onChange={(e) => update({ versionAddButtonText: e.target.value })}
|
||||
placeholder="+ 라우팅 버전 추가"
|
||||
className="h-7 w-[140px] text-xs"
|
||||
/>
|
||||
<Input value={config.versionAddButtonText || ""} onChange={(e) => update({ versionAddButtonText: e.target.value })} placeholder="+ 라우팅 버전 추가" className="h-7 w-[140px] text-xs" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">공정 추가 버튼 텍스트</span>
|
||||
<Input
|
||||
value={config.processAddButtonText || ""}
|
||||
onChange={(e) => update({ processAddButtonText: e.target.value })}
|
||||
placeholder="+ 공정 추가"
|
||||
className="h-7 w-[140px] text-xs"
|
||||
/>
|
||||
<Input value={config.processAddButtonText || ""} onChange={(e) => update({ processAddButtonText: e.target.value })} placeholder="+ 공정 추가" className="h-7 w-[140px] text-xs" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">첫 번째 버전 자동 선택</p>
|
||||
<p className="text-[11px] text-muted-foreground">품목 선택 시 첫 버전을 자동으로 선택해요</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.autoSelectFirstVersion !== false}
|
||||
onCheckedChange={(checked) => update({ autoSelectFirstVersion: checked })}
|
||||
/>
|
||||
<Switch checked={config.autoSelectFirstVersion !== false} onCheckedChange={(checked) => update({ autoSelectFirstVersion: checked })} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">읽기 전용</p>
|
||||
<p className="text-[11px] text-muted-foreground">추가/수정/삭제 버튼을 숨겨요</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => update({ readonly: checked })}
|
||||
/>
|
||||
<Switch checked={config.readonly || false} onCheckedChange={(checked) => update({ readonly: checked })} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
|
@ -752,5 +606,4 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
|
|||
};
|
||||
|
||||
V2ItemRoutingConfigPanel.displayName = "V2ItemRoutingConfigPanel";
|
||||
|
||||
export default V2ItemRoutingConfigPanel;
|
||||
|
|
|
|||
|
|
@ -1,217 +1,203 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } from "lucide-react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} 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 { useToast } from "@/hooks/use-toast";
|
||||
import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types";
|
||||
import { defaultConfig } from "./config";
|
||||
import { ItemRoutingComponentProps, ColumnDef } from "./types";
|
||||
import { useItemRouting } from "./hooks/useItemRouting";
|
||||
|
||||
const DEFAULT_ITEM_COLS: ColumnDef[] = [
|
||||
{ name: "item_name", label: "품명" },
|
||||
{ name: "item_code", label: "품번", width: 100 },
|
||||
];
|
||||
|
||||
export function ItemRoutingComponent({
|
||||
config: configProp,
|
||||
isPreview,
|
||||
screenId,
|
||||
}: ItemRoutingComponentProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const resolvedConfig = React.useMemo(() => {
|
||||
if (configProp?.itemListMode === "registered" && !configProp?.screenCode && screenId) {
|
||||
return { ...configProp, screenCode: `screen_${screenId}` };
|
||||
}
|
||||
return configProp;
|
||||
}, [configProp, screenId]);
|
||||
|
||||
const {
|
||||
config,
|
||||
items,
|
||||
versions,
|
||||
details,
|
||||
loading,
|
||||
selectedItemCode,
|
||||
selectedItemName,
|
||||
selectedVersionId,
|
||||
fetchItems,
|
||||
selectItem,
|
||||
selectVersion,
|
||||
refreshVersions,
|
||||
refreshDetails,
|
||||
deleteDetail,
|
||||
deleteVersion,
|
||||
setDefaultVersion,
|
||||
unsetDefaultVersion,
|
||||
} = useItemRouting(configProp || {});
|
||||
config, items, allItems, versions, details, loading,
|
||||
selectedItemCode, selectedItemName, selectedVersionId, isRegisteredMode,
|
||||
fetchItems, fetchRegisteredItems, fetchAllItems,
|
||||
registerItemsBatch, unregisterItem,
|
||||
selectItem, selectVersion, refreshVersions, refreshDetails,
|
||||
deleteDetail, deleteVersion, setDefaultVersion, unsetDefaultVersion,
|
||||
} = useItemRouting(resolvedConfig || {});
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: "version" | "detail";
|
||||
id: string;
|
||||
name: string;
|
||||
type: "version" | "detail"; id: string; name: string;
|
||||
} | 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);
|
||||
useEffect(() => {
|
||||
if (!mountedRef.current) {
|
||||
mountedRef.current = true;
|
||||
fetchItems();
|
||||
if (isRegisteredMode) fetchRegisteredItems();
|
||||
else fetchItems();
|
||||
}
|
||||
}, [fetchItems]);
|
||||
}, [fetchItems, fetchRegisteredItems, isRegisteredMode]);
|
||||
|
||||
// 모달 저장 성공 감지 -> 데이터 새로고침
|
||||
// 모달 저장 성공 감지
|
||||
const refreshVersionsRef = React.useRef(refreshVersions);
|
||||
const refreshDetailsRef = React.useRef(refreshDetails);
|
||||
refreshVersionsRef.current = refreshVersions;
|
||||
refreshDetailsRef.current = refreshDetails;
|
||||
|
||||
useEffect(() => {
|
||||
const handleSaveSuccess = () => {
|
||||
refreshVersionsRef.current();
|
||||
refreshDetailsRef.current();
|
||||
};
|
||||
window.addEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||
return () => {
|
||||
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||
};
|
||||
const h = () => { refreshVersionsRef.current(); refreshDetailsRef.current(); };
|
||||
window.addEventListener("saveSuccessInModal", h);
|
||||
return () => window.removeEventListener("saveSuccessInModal", h);
|
||||
}, []);
|
||||
|
||||
// 품목 검색
|
||||
// 검색
|
||||
const handleSearch = useCallback(() => {
|
||||
fetchItems(searchText || undefined);
|
||||
}, [fetchItems, searchText]);
|
||||
if (isRegisteredMode) fetchRegisteredItems(searchText || undefined);
|
||||
else fetchItems(searchText || undefined);
|
||||
}, [fetchItems, fetchRegisteredItems, isRegisteredMode, searchText]);
|
||||
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
// ──── 품목 추가 모달 ────
|
||||
const handleOpenAddDialog = useCallback(() => {
|
||||
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(() => {
|
||||
if (!selectedItemCode) {
|
||||
toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const screenId = config.modals.versionAddScreenId;
|
||||
if (!screenId) return;
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId,
|
||||
urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable },
|
||||
splitPanelParentData: {
|
||||
[config.dataSource.routingVersionFkColumn]: selectedItemCode,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
if (!selectedItemCode) { toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" }); return; }
|
||||
const sid = config.modals.versionAddScreenId;
|
||||
if (!sid) return;
|
||||
window.dispatchEvent(new CustomEvent("openScreenModal", {
|
||||
detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable },
|
||||
splitPanelParentData: { [config.dataSource.routingVersionFkColumn]: selectedItemCode } },
|
||||
}));
|
||||
}, [selectedItemCode, config, toast]);
|
||||
|
||||
// 공정 추가 모달
|
||||
const handleAddProcess = useCallback(() => {
|
||||
if (!selectedVersionId) {
|
||||
toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const screenId = config.modals.processAddScreenId;
|
||||
if (!screenId) return;
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId,
|
||||
urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
|
||||
splitPanelParentData: {
|
||||
[config.dataSource.routingDetailFkColumn]: selectedVersionId,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
if (!selectedVersionId) { toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" }); return; }
|
||||
const sid = config.modals.processAddScreenId;
|
||||
if (!sid) return;
|
||||
window.dispatchEvent(new CustomEvent("openScreenModal", {
|
||||
detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
|
||||
splitPanelParentData: { [config.dataSource.routingDetailFkColumn]: selectedVersionId } },
|
||||
}));
|
||||
}, [selectedVersionId, config, toast]);
|
||||
|
||||
// 공정 수정 모달
|
||||
const handleEditProcess = useCallback(
|
||||
(detail: Record<string, any>) => {
|
||||
const screenId = config.modals.processEditScreenId;
|
||||
if (!screenId) return;
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId,
|
||||
urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable },
|
||||
editData: detail,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
[config]
|
||||
const sid = config.modals.processEditScreenId;
|
||||
if (!sid) return;
|
||||
window.dispatchEvent(new CustomEvent("openScreenModal", {
|
||||
detail: { screenId: sid, urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable }, editData: detail },
|
||||
}));
|
||||
}, [config]
|
||||
);
|
||||
|
||||
// 기본 버전 토글
|
||||
const handleToggleDefault = useCallback(
|
||||
async (versionId: string, currentIsDefault: boolean) => {
|
||||
let success: boolean;
|
||||
if (currentIsDefault) {
|
||||
success = await unsetDefaultVersion(versionId);
|
||||
if (success) toast({ title: "기본 버전이 해제되었습니다" });
|
||||
} else {
|
||||
success = await setDefaultVersion(versionId);
|
||||
if (success) toast({ title: "기본 버전으로 설정되었습니다" });
|
||||
}
|
||||
if (!success) {
|
||||
toast({ title: "기본 버전 변경 실패", variant: "destructive" });
|
||||
}
|
||||
const success = currentIsDefault ? await unsetDefaultVersion(versionId) : await setDefaultVersion(versionId);
|
||||
if (success) toast({ title: currentIsDefault ? "기본 버전이 해제되었습니다" : "기본 버전으로 설정되었습니다" });
|
||||
else toast({ title: "기본 버전 변경 실패", variant: "destructive" });
|
||||
},
|
||||
[setDefaultVersion, unsetDefaultVersion, toast]
|
||||
);
|
||||
|
||||
// 삭제 확인
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
let success = false;
|
||||
if (deleteTarget.type === "version") {
|
||||
success = await deleteVersion(deleteTarget.id);
|
||||
} else {
|
||||
success = await deleteDetail(deleteTarget.id);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast({ title: `${deleteTarget.name} 삭제 완료` });
|
||||
} else {
|
||||
toast({ title: "삭제 실패", variant: "destructive" });
|
||||
}
|
||||
const success = deleteTarget.type === "version"
|
||||
? await deleteVersion(deleteTarget.id) : await deleteDetail(deleteTarget.id);
|
||||
toast({ title: success ? `${deleteTarget.name} 삭제 완료` : "삭제 실패", variant: success ? undefined : "destructive" });
|
||||
setDeleteTarget(null);
|
||||
}, [deleteTarget, deleteVersion, deleteDetail, toast]);
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/10 p-4">
|
||||
<div className="text-center">
|
||||
<ListOrdered className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
품목별 라우팅 관리
|
||||
</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">품목별 라우팅 관리</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||
품목 선택 - 라우팅 버전 - 공정 순서
|
||||
{isRegisteredMode ? "등록 품목 모드" : "전체 품목 모드"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -221,94 +207,111 @@ export function ItemRoutingComponent({
|
|||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-background">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 좌측 패널: 품목 목록 */}
|
||||
<div
|
||||
style={{ width: `${splitRatio}%` }}
|
||||
className="flex shrink-0 flex-col overflow-hidden border-r"
|
||||
>
|
||||
<div className="border-b px-3 py-2">
|
||||
{/* ════ 좌측 패널: 품목 목록 (테이블) ════ */}
|
||||
<div style={{ width: `${splitRatio}%` }} className="flex shrink-0 flex-col overflow-hidden border-r">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{config.leftPanelTitle || "품목 목록"}
|
||||
{isRegisteredMode && (
|
||||
<span className="ml-1.5 text-[10px] font-normal text-muted-foreground">(등록 모드)</span>
|
||||
)}
|
||||
</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 className="flex gap-1.5 border-b px-3 py-2">
|
||||
<Input
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="품목명/품번 검색"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input value={searchText} onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
|
||||
placeholder="품목명/품번 검색" className="h-8 text-xs" />
|
||||
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" onClick={handleSearch}>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 품목 리스트 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 품목 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{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">
|
||||
{loading ? "로딩 중..." : "품목이 없습니다"}
|
||||
{loading ? "로딩 중..." : isRegisteredMode ? "등록된 품목이 없습니다" : "품목이 없습니다"}
|
||||
</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 className="divide-y">
|
||||
{items.map((item) => {
|
||||
const itemCode =
|
||||
item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number;
|
||||
const itemName =
|
||||
item[config.dataSource.itemNameColumn] || item.item_name;
|
||||
const isSelected = selectedItemCode === itemCode;
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{itemDisplayCols.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>
|
||||
))}
|
||||
{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 (
|
||||
<button
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted/50",
|
||||
isSelected && "bg-primary/10 font-medium"
|
||||
)}
|
||||
onClick={() => selectItem(itemCode, itemName)}
|
||||
>
|
||||
<Package className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{itemName}</p>
|
||||
<p className="truncate text-muted-foreground">{itemCode}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<TableRow key={item.registered_id || item.id}
|
||||
className={cn("cursor-pointer group", isSelected && "bg-primary/10")}
|
||||
onClick={() => selectItem(itemCode, itemName)}>
|
||||
{itemDisplayCols.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>
|
||||
))}
|
||||
{isRegisteredMode && !config.readonly && item.registered_id && (
|
||||
<TableCell className="py-1.5 text-center">
|
||||
<Button variant="ghost" size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => { e.stopPropagation(); handleUnregisterItem(item.registered_id, itemName); }}
|
||||
title="등록 해제">
|
||||
<X className="h-3 w-3 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 패널: 버전 + 공정 */}
|
||||
{/* ════ 우측 패널: 버전 + 공정 ════ */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{selectedItemCode ? (
|
||||
<>
|
||||
{/* 헤더: 선택된 품목 + 버전 추가 */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{selectedItemName}</h3>
|
||||
<p className="text-xs text-muted-foreground">{selectedItemCode}</p>
|
||||
</div>
|
||||
{!config.readonly && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={handleAddVersion}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{config.versionAddButtonText || "+ 라우팅 버전 추가"}
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleAddVersion}>
|
||||
<Plus className="h-3 w-3" /> {config.versionAddButtonText || "+ 라우팅 버전 추가"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버전 선택 버튼들 */}
|
||||
{versions.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 border-b px-4 py-2">
|
||||
<span className="mr-1 text-xs text-muted-foreground">버전:</span>
|
||||
|
|
@ -317,50 +320,24 @@ export function ItemRoutingComponent({
|
|||
const isDefault = ver.is_default === true;
|
||||
return (
|
||||
<div key={ver.id} className="flex items-center gap-0.5">
|
||||
<Badge
|
||||
variant={isActive ? "default" : "outline"}
|
||||
className={cn(
|
||||
"cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
|
||||
<Badge variant={isActive ? "default" : "outline"}
|
||||
className={cn("cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
|
||||
isActive && "bg-primary text-primary-foreground",
|
||||
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700"
|
||||
)}
|
||||
onClick={() => selectVersion(ver.id)}
|
||||
>
|
||||
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700")}
|
||||
onClick={() => selectVersion(ver.id)}>
|
||||
{isDefault && <Star className="mr-1 h-3 w-3 fill-current" />}
|
||||
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
|
||||
</Badge>
|
||||
{!config.readonly && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-5 w-5",
|
||||
isDefault
|
||||
? "text-amber-500 hover:text-amber-600"
|
||||
: "text-muted-foreground hover:text-amber-500"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleDefault(ver.id, isDefault);
|
||||
}}
|
||||
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}
|
||||
>
|
||||
<Button variant="ghost" size="icon"
|
||||
className={cn("h-5 w-5", isDefault ? "text-amber-500 hover:text-amber-600" : "text-muted-foreground hover:text-amber-500")}
|
||||
onClick={(e) => { e.stopPropagation(); handleToggleDefault(ver.id, isDefault); }}
|
||||
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}>
|
||||
<Star className={cn("h-3 w-3", isDefault && "fill-current")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget({
|
||||
type: "version",
|
||||
id: ver.id,
|
||||
name: ver.version_name || ver.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteTarget({ type: "version", id: ver.id, name: ver.version_name || ver.id }); }}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
|
|
@ -371,112 +348,65 @@ export function ItemRoutingComponent({
|
|||
</div>
|
||||
) : (
|
||||
<div className="border-b px-4 py-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
라우팅 버전이 없습니다. 버전을 추가해주세요.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">라우팅 버전이 없습니다. 버전을 추가해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공정 테이블 */}
|
||||
{selectedVersionId ? (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* 공정 테이블 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
{config.rightPanelTitle || "공정 순서"} ({details.length}건)
|
||||
</h4>
|
||||
{!config.readonly && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={handleAddProcess}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{config.processAddButtonText || "+ 공정 추가"}
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleAddProcess}>
|
||||
<Plus className="h-3 w-3" /> {config.processAddButtonText || "+ 공정 추가"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto px-4 pb-4">
|
||||
{details.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading ? "로딩 중..." : "등록된 공정이 없습니다"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{loading ? "로딩 중..." : "등록된 공정이 없습니다"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{config.processColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.name}
|
||||
<TableHead key={col.name}
|
||||
style={{ width: col.width ? `${col.width}px` : undefined }}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
col.align === "center" && "text-center",
|
||||
col.align === "right" && "text-right"
|
||||
)}
|
||||
>
|
||||
className={cn("text-xs", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
{!config.readonly && (
|
||||
<TableHead className="w-[80px] text-center text-xs">
|
||||
관리
|
||||
</TableHead>
|
||||
)}
|
||||
{!config.readonly && <TableHead className="w-[80px] text-center text-xs">관리</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{details.map((detail) => (
|
||||
<TableRow key={detail.id}>
|
||||
{config.processColumns.map((col) => {
|
||||
let cellValue = detail[col.name];
|
||||
if (cellValue == null) {
|
||||
const aliasKey = Object.keys(detail).find(
|
||||
(k) => k.endsWith(`_${col.name}`)
|
||||
);
|
||||
if (aliasKey) cellValue = detail[aliasKey];
|
||||
let v = detail[col.name];
|
||||
if (v == null) {
|
||||
const ak = Object.keys(detail).find((k) => k.endsWith(`_${col.name}`));
|
||||
if (ak) v = detail[ak];
|
||||
}
|
||||
return (
|
||||
<TableCell
|
||||
key={col.name}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
col.align === "center" && "text-center",
|
||||
col.align === "right" && "text-right"
|
||||
)}
|
||||
>
|
||||
{cellValue ?? "-"}
|
||||
<TableCell key={col.name}
|
||||
className={cn("text-xs", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
|
||||
{v ?? "-"}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
{!config.readonly && (
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleEditProcess(detail)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleEditProcess(detail)}>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() =>
|
||||
setDeleteTarget({
|
||||
type: "detail",
|
||||
id: detail.id,
|
||||
name: `공정 ${detail.seq_no || detail.id}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget({ type: "detail", id: detail.id, name: `공정 ${detail.seq_no || detail.id}` })}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -492,9 +422,7 @@ export function ItemRoutingComponent({
|
|||
) : (
|
||||
versions.length > 0 && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
라우팅 버전을 선택해주세요
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">라우팅 버전을 선택해주세요</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
|
@ -502,43 +430,121 @@ export function ItemRoutingComponent({
|
|||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
||||
<ListOrdered className="mb-3 h-12 w-12 text-muted-foreground/30" />
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
좌측에서 품목을 선택하세요
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||
품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다
|
||||
</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">좌측에서 품목을 선택하세요</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/70">품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
{/* ════ 삭제 확인 ════ */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base">삭제 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-sm">
|
||||
{deleteTarget?.name}을(를) 삭제하시겠습니까?
|
||||
{deleteTarget?.type === "version" && (
|
||||
<>
|
||||
<br />
|
||||
해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다.
|
||||
</>
|
||||
)}
|
||||
{deleteTarget?.type === "version" && (<><br />해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다.</>)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<AlertDialogAction onClick={handleConfirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
|
|||
static componentDefinition = V2ItemRoutingDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
const { formData, isPreview, config, tableName } = this.props as Record<
|
||||
const { formData, isPreview, config, tableName, screenId } = this.props as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
|
@ -20,6 +20,7 @@ export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
|
|||
formData={formData as Record<string, unknown>}
|
||||
tableName={tableName as string}
|
||||
isPreview={isPreview as boolean}
|
||||
screenId={screenId as number | string}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,4 +35,15 @@ export const defaultConfig: ItemRoutingConfig = {
|
|||
autoSelectFirstVersion: true,
|
||||
versionAddButtonText: "+ 라우팅 버전 추가",
|
||||
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";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
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";
|
||||
|
||||
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>) {
|
||||
const configKey = useMemo(
|
||||
() => JSON.stringify(configPartial),
|
||||
|
|
@ -27,21 +36,81 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
configRef.current = config;
|
||||
|
||||
const [items, setItems] = useState<ItemData[]>([]);
|
||||
const [allItems, setAllItems] = useState<ItemData[]>([]);
|
||||
const [versions, setVersions] = useState<RoutingVersionData[]>([]);
|
||||
const [details, setDetails] = useState<RoutingDetailData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 선택 상태
|
||||
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
||||
const [selectedItemName, setSelectedItemName] = useState<string | null>(null);
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||
|
||||
// 품목 목록 조회
|
||||
const 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(
|
||||
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 {
|
||||
setLoading(true);
|
||||
const ds = configRef.current.dataSource;
|
||||
const cols = configRef.current.itemDisplayColumns;
|
||||
const extra = getExtraColumnNames(cols);
|
||||
const params = new URLSearchParams({
|
||||
tableName: ds.itemTable,
|
||||
nameColumn: ds.itemNameColumn,
|
||||
|
|
@ -49,13 +118,16 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
routingTable: ds.routingVersionTable,
|
||||
routingFkColumn: ds.routingVersionFkColumn,
|
||||
...(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) {
|
||||
setItems(res.data.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("품목 조회 실패", err);
|
||||
console.error("등록 품목 조회 실패", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -64,7 +136,104 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
[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(
|
||||
async (itemCode: string) => {
|
||||
try {
|
||||
|
|
@ -94,7 +263,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
[configKey]
|
||||
);
|
||||
|
||||
// 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함
|
||||
const fetchDetails = useCallback(
|
||||
async (versionId: string) => {
|
||||
try {
|
||||
|
|
@ -128,18 +296,14 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
[configKey]
|
||||
);
|
||||
|
||||
// 품목 선택
|
||||
const selectItem = useCallback(
|
||||
async (itemCode: string, itemName: string) => {
|
||||
setSelectedItemCode(itemCode);
|
||||
setSelectedItemName(itemName);
|
||||
setSelectedVersionId(null);
|
||||
setDetails([]);
|
||||
|
||||
const versionList = await fetchVersions(itemCode);
|
||||
|
||||
if (versionList.length > 0) {
|
||||
// 기본 버전 우선, 없으면 첫번째 버전 선택
|
||||
const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default);
|
||||
const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null);
|
||||
if (targetVersion) {
|
||||
|
|
@ -151,7 +315,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
[fetchVersions, fetchDetails]
|
||||
);
|
||||
|
||||
// 버전 선택
|
||||
const selectVersion = useCallback(
|
||||
async (versionId: string) => {
|
||||
setSelectedVersionId(versionId);
|
||||
|
|
@ -160,7 +323,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
[fetchDetails]
|
||||
);
|
||||
|
||||
// 모달에서 데이터 변경 후 새로고침
|
||||
const refreshVersions = useCallback(async () => {
|
||||
if (selectedItemCode) {
|
||||
const versionList = await fetchVersions(selectedItemCode);
|
||||
|
|
@ -180,7 +342,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
}
|
||||
}, [selectedVersionId, fetchDetails]);
|
||||
|
||||
// 공정 삭제
|
||||
const deleteDetail = useCallback(
|
||||
async (detailId: string) => {
|
||||
try {
|
||||
|
|
@ -189,19 +350,13 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
`/table-management/tables/${ds.routingDetailTable}/delete`,
|
||||
{ data: [{ id: detailId }] }
|
||||
);
|
||||
if (res.data?.success) {
|
||||
await refreshDetails();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("공정 삭제 실패", err);
|
||||
}
|
||||
if (res.data?.success) { await refreshDetails(); return true; }
|
||||
} catch (err) { console.error("공정 삭제 실패", err); }
|
||||
return false;
|
||||
},
|
||||
[refreshDetails]
|
||||
);
|
||||
|
||||
// 버전 삭제
|
||||
const deleteVersion = useCallback(
|
||||
async (versionId: string) => {
|
||||
try {
|
||||
|
|
@ -211,22 +366,16 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
{ data: [{ id: versionId }] }
|
||||
);
|
||||
if (res.data?.success) {
|
||||
if (selectedVersionId === versionId) {
|
||||
setSelectedVersionId(null);
|
||||
setDetails([]);
|
||||
}
|
||||
if (selectedVersionId === versionId) { setSelectedVersionId(null); setDetails([]); }
|
||||
await refreshVersions();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("버전 삭제 실패", err);
|
||||
}
|
||||
} catch (err) { console.error("버전 삭제 실패", err); }
|
||||
return false;
|
||||
},
|
||||
[selectedVersionId, refreshVersions]
|
||||
);
|
||||
|
||||
// 기본 버전 설정
|
||||
const setDefaultVersion = useCallback(
|
||||
async (versionId: string) => {
|
||||
try {
|
||||
|
|
@ -236,20 +385,15 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
routingFkColumn: ds.routingVersionFkColumn,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
if (selectedItemCode) {
|
||||
await fetchVersions(selectedItemCode);
|
||||
}
|
||||
if (selectedItemCode) await fetchVersions(selectedItemCode);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("기본 버전 설정 실패", err);
|
||||
}
|
||||
} catch (err) { console.error("기본 버전 설정 실패", err); }
|
||||
return false;
|
||||
},
|
||||
[selectedItemCode, fetchVersions]
|
||||
);
|
||||
|
||||
// 기본 버전 해제
|
||||
const unsetDefaultVersion = useCallback(
|
||||
async (versionId: string) => {
|
||||
try {
|
||||
|
|
@ -258,14 +402,10 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
routingVersionTable: ds.routingVersionTable,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
if (selectedItemCode) {
|
||||
await fetchVersions(selectedItemCode);
|
||||
}
|
||||
if (selectedItemCode) await fetchVersions(selectedItemCode);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("기본 버전 해제 실패", err);
|
||||
}
|
||||
} catch (err) { console.error("기본 버전 해제 실패", err); }
|
||||
return false;
|
||||
},
|
||||
[selectedItemCode, fetchVersions]
|
||||
|
|
@ -274,13 +414,20 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
return {
|
||||
config,
|
||||
items,
|
||||
allItems,
|
||||
versions,
|
||||
details,
|
||||
loading,
|
||||
selectedItemCode,
|
||||
selectedItemName,
|
||||
selectedVersionId,
|
||||
isRegisteredMode,
|
||||
fetchItems,
|
||||
fetchRegisteredItems,
|
||||
fetchAllItems,
|
||||
registerItem,
|
||||
registerItemsBatch,
|
||||
unregisterItem,
|
||||
selectItem,
|
||||
selectVersion,
|
||||
refreshVersions,
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ export interface ItemRoutingDataSource {
|
|||
itemNameColumn: string;
|
||||
itemCodeColumn: string;
|
||||
routingVersionTable: string;
|
||||
routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK
|
||||
routingVersionFkColumn: string;
|
||||
routingVersionNameColumn: string;
|
||||
routingDetailTable: string;
|
||||
routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK
|
||||
routingDetailFkColumn: string;
|
||||
processTable: string;
|
||||
processNameColumn: string;
|
||||
processCodeColumn: string;
|
||||
|
|
@ -26,14 +26,24 @@ export interface ItemRoutingModals {
|
|||
processEditScreenId?: number;
|
||||
}
|
||||
|
||||
// 공정 테이블 컬럼 정의
|
||||
export interface ProcessColumnDef {
|
||||
// 컬럼 정의 (공정/품목 공용)
|
||||
export interface ColumnDef {
|
||||
name: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
}
|
||||
|
||||
// 공정 테이블 컬럼 정의 (기존 호환)
|
||||
export type ProcessColumnDef = ColumnDef;
|
||||
|
||||
// 품목 필터 조건
|
||||
export interface ItemFilterCondition {
|
||||
column: string;
|
||||
operator: "equals" | "contains" | "not_equals";
|
||||
value: string;
|
||||
}
|
||||
|
||||
// 전체 Config
|
||||
export interface ItemRoutingConfig {
|
||||
dataSource: ItemRoutingDataSource;
|
||||
|
|
@ -46,6 +56,14 @@ export interface ItemRoutingConfig {
|
|||
autoSelectFirstVersion?: boolean;
|
||||
versionAddButtonText?: string;
|
||||
processAddButtonText?: string;
|
||||
itemListMode?: "all" | "registered";
|
||||
screenCode?: string;
|
||||
/** 좌측 품목 목록에 표시할 컬럼 */
|
||||
itemDisplayColumns?: ColumnDef[];
|
||||
/** 품목 추가 모달에 표시할 컬럼 */
|
||||
modalDisplayColumns?: ColumnDef[];
|
||||
/** 품목 조회 시 사전 필터 조건 */
|
||||
itemFilterConditions?: ItemFilterCondition[];
|
||||
}
|
||||
|
||||
// 컴포넌트 Props
|
||||
|
|
@ -54,6 +72,7 @@ export interface ItemRoutingComponentProps {
|
|||
formData?: Record<string, any>;
|
||||
isPreview?: boolean;
|
||||
tableName?: string;
|
||||
screenId?: number | string;
|
||||
}
|
||||
|
||||
// 데이터 모델
|
||||
|
|
|
|||
Loading…
Reference in New Issue