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:
kjs 2026-03-13 11:26:59 +09:00
parent 772a10258c
commit 3df9a39ebe
9 changed files with 1684 additions and 798 deletions

View File

@ -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 });
}
}

View File

@ -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;

View File

@ -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` 필수 (멀티테넌시)

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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}
/>
);
}

View File

@ -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: [],
};

View File

@ -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,

View File

@ -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;
}
// 데이터 모델