jskim-node #391

Merged
kjs merged 3 commits from jskim-node into main 2026-02-24 12:38:43 +09:00
26 changed files with 3279 additions and 4 deletions
Showing only changes of commit 4f6d9a689d - Show all commits

View File

@ -123,6 +123,7 @@ import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRou
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -304,6 +305,7 @@ app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석

View File

@ -0,0 +1,573 @@
/**
*
* /
*/
import { Request, Response } from "express";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// ============================================================
// 품목/라우팅/공정 조회 (좌측 트리 데이터)
// ============================================================
/**
*
* 쿼리: tableName(), nameColumn, codeColumn
*/
export async function getItemsWithRouting(req: Request, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const {
tableName = "item_info",
nameColumn = "item_name",
codeColumn = "item_number",
routingTable = "item_routing_version",
routingFkColumn = "item_code",
search = "",
} = req.query as Record<string, string>;
const searchCondition = search
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
: "";
const params: any[] = [companyCode];
if (search) params.push(`%${search}%`);
const query = `
SELECT DISTINCT
i.id,
i.${nameColumn} AS item_name,
i.${codeColumn} AS item_code
FROM ${tableName} i
INNER JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
AND rv.company_code = i.company_code
WHERE i.company_code = $1
${searchCondition}
ORDER BY i.${codeColumn}
`;
const result = await getPool().query(query, params);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* + ( )
*/
export async function getRoutingsWithProcesses(req: Request, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { itemCode } = req.params;
const {
routingVersionTable = "item_routing_version",
routingDetailTable = "item_routing_detail",
routingFkColumn = "item_code",
processTable = "process_mng",
processNameColumn = "process_name",
processCodeColumn = "process_code",
} = req.query as Record<string, string>;
// 라우팅 버전 목록
const versionsQuery = `
SELECT id, version_name, description, created_date
FROM ${routingVersionTable}
WHERE ${routingFkColumn} = $1 AND company_code = $2
ORDER BY created_date DESC
`;
const versionsResult = await getPool().query(versionsQuery, [
itemCode,
companyCode,
]);
// 각 버전별 공정 목록
const routings = [];
for (const version of versionsResult.rows) {
const detailsQuery = `
SELECT
rd.id AS routing_detail_id,
rd.seq_no,
rd.process_code,
rd.is_required,
rd.work_type,
p.${processNameColumn} AS process_name
FROM ${routingDetailTable} rd
LEFT JOIN ${processTable} p ON p.${processCodeColumn} = rd.process_code
AND p.company_code = rd.company_code
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
ORDER BY rd.seq_no::integer
`;
const detailsResult = await getPool().query(detailsQuery, [
version.id,
companyCode,
]);
routings.push({
...version,
processes: detailsResult.rows,
});
}
return res.json({ success: true, data: routings });
} catch (error: any) {
logger.error("라우팅/공정 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ============================================================
// 작업 항목 CRUD
// ============================================================
/**
* (phase별 )
*/
export async function getWorkItems(req: Request, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { routingDetailId } = req.params;
const query = `
SELECT
wi.id,
wi.routing_detail_id,
wi.work_phase,
wi.title,
wi.is_required,
wi.sort_order,
wi.description,
wi.created_date,
(SELECT COUNT(*) FROM process_work_item_detail d
WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code
)::integer AS detail_count
FROM process_work_item wi
WHERE wi.routing_detail_id = $1 AND wi.company_code = $2
ORDER BY wi.work_phase, wi.sort_order, wi.created_date
`;
const result = await getPool().query(query, [routingDetailId, companyCode]);
// phase별 그룹핑
const grouped: Record<string, any[]> = {};
for (const row of result.rows) {
const phase = row.work_phase;
if (!grouped[phase]) grouped[phase] = [];
grouped[phase].push(row);
}
return res.json({ success: true, data: grouped, items: result.rows });
} catch (error: any) {
logger.error("작업 항목 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
*
*/
export async function createWorkItem(req: Request, res: Response) {
try {
const companyCode = req.user?.companyCode;
const writer = req.user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { routing_detail_id, work_phase, title, is_required, sort_order, description } = req.body;
if (!routing_detail_id || !work_phase || !title) {
return res.status(400).json({
success: false,
message: "routing_detail_id, work_phase, title은 필수입니다",
});
}
const query = `
INSERT INTO process_work_item
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const result = await getPool().query(query, [
companyCode,
routing_detail_id,
work_phase,
title,
is_required || "N",
sort_order || 0,
description || null,
writer,
]);
logger.info("작업 항목 생성", { companyCode, id: result.rows[0].id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("작업 항목 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
*
*/
export async function updateWorkItem(req: Request, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
const { title, is_required, sort_order, description } = req.body;
const query = `
UPDATE process_work_item
SET title = COALESCE($1, title),
is_required = COALESCE($2, is_required),
sort_order = COALESCE($3, sort_order),
description = COALESCE($4, description),
updated_date = NOW()
WHERE id = $5 AND company_code = $6
RETURNING *
`;
const result = await getPool().query(query, [
title,
is_required,
sort_order,
description,
id,
companyCode,
]);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" });
}
logger.info("작업 항목 수정", { companyCode, id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("작업 항목 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* ( )
*/
export async function deleteWorkItem(req: Request, res: Response) {
const client = await getPool().connect();
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
await client.query("BEGIN");
// 상세 먼저 삭제
await client.query(
"DELETE FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2",
[id, companyCode]
);
// 항목 삭제
const result = await client.query(
"DELETE FROM process_work_item WHERE id = $1 AND company_code = $2 RETURNING id",
[id, companyCode]
);
if (result.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" });
}
await client.query("COMMIT");
logger.info("작업 항목 삭제", { companyCode, id });
return res.json({ success: true });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("작업 항목 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// ============================================================
// 작업 항목 상세 CRUD
// ============================================================
/**
*
*/
export async function getWorkItemDetails(req: Request, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { workItemId } = req.params;
const query = `
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, created_date
FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order, created_date
`;
const result = await getPool().query(query, [workItemId, companyCode]);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("작업 항목 상세 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
*
*/
export async function createWorkItemDetail(req: Request, res: Response) {
try {
const companyCode = req.user?.companyCode;
const writer = req.user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { work_item_id, detail_type, content, is_required, sort_order, remark } = req.body;
if (!work_item_id || !content) {
return res.status(400).json({
success: false,
message: "work_item_id, content는 필수입니다",
});
}
// work_item이 같은 company_code인지 검증
const ownerCheck = await getPool().query(
"SELECT id FROM process_work_item WHERE id = $1 AND company_code = $2",
[work_item_id, companyCode]
);
if (ownerCheck.rowCount === 0) {
return res.status(403).json({ success: false, message: "권한이 없습니다" });
}
const query = `
INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const result = await getPool().query(query, [
companyCode,
work_item_id,
detail_type || null,
content,
is_required || "N",
sort_order || 0,
remark || null,
writer,
]);
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("작업 항목 상세 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
*
*/
export async function updateWorkItemDetail(req: Request, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
const { detail_type, content, is_required, sort_order, remark } = req.body;
const query = `
UPDATE process_work_item_detail
SET detail_type = COALESCE($1, detail_type),
content = COALESCE($2, content),
is_required = COALESCE($3, is_required),
sort_order = COALESCE($4, sort_order),
remark = COALESCE($5, remark),
updated_date = NOW()
WHERE id = $6 AND company_code = $7
RETURNING *
`;
const result = await getPool().query(query, [
detail_type,
content,
is_required,
sort_order,
remark,
id,
companyCode,
]);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" });
}
logger.info("작업 항목 상세 수정", { companyCode, id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("작업 항목 상세 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
*
*/
export async function deleteWorkItemDetail(req: Request, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
const result = await getPool().query(
"DELETE FROM process_work_item_detail WHERE id = $1 AND company_code = $2 RETURNING id",
[id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" });
}
logger.info("작업 항목 상세 삭제", { companyCode, id });
return res.json({ success: true });
} catch (error: any) {
logger.error("작업 항목 상세 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ============================================================
// 전체 저장 (일괄)
// ============================================================
/**
* 저장: 작업 +
* replace
*/
export async function saveAll(req: Request, res: Response) {
const client = await getPool().connect();
try {
const companyCode = req.user?.companyCode;
const writer = req.user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { routing_detail_id, items } = req.body;
if (!routing_detail_id || !Array.isArray(items)) {
return res.status(400).json({
success: false,
message: "routing_detail_id와 items 배열이 필요합니다",
});
}
await client.query("BEGIN");
// 기존 상세 삭제
await client.query(
`DELETE FROM process_work_item_detail
WHERE work_item_id IN (
SELECT id FROM process_work_item
WHERE routing_detail_id = $1 AND company_code = $2
)`,
[routing_detail_id, companyCode]
);
// 기존 항목 삭제
await client.query(
"DELETE FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2",
[routing_detail_id, companyCode]
);
// 새 항목 + 상세 삽입
for (const item of items) {
const itemResult = await client.query(
`INSERT INTO process_work_item
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
[
companyCode,
routing_detail_id,
item.work_phase,
item.title,
item.is_required || "N",
item.sort_order || 0,
item.description || null,
writer,
]
);
const workItemId = itemResult.rows[0].id;
if (Array.isArray(item.details)) {
for (const detail of item.details) {
await client.query(
`INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
companyCode,
workItemId,
detail.detail_type || null,
detail.content,
detail.is_required || "N",
detail.sort_order || 0,
detail.remark || null,
writer,
]
);
}
}
}
await client.query("COMMIT");
logger.info("작업기준 전체 저장", { companyCode, routing_detail_id, itemCount: items.length });
return res.json({ success: true, message: "저장 완료" });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("작업기준 전체 저장 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}

View File

@ -0,0 +1,29 @@
/**
*
*/
import express from "express";
import * as ctrl from "../controllers/processWorkStandardController";
const router = express.Router();
// 품목/라우팅/공정 조회 (좌측 트리)
router.get("/items", ctrl.getItemsWithRouting);
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
// 작업 항목 CRUD
router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems);
router.post("/work-items", ctrl.createWorkItem);
router.put("/work-items/:id", ctrl.updateWorkItem);
router.delete("/work-items/:id", ctrl.deleteWorkItem);
// 작업 항목 상세 CRUD
router.get("/work-items/:workItemId/details", ctrl.getWorkItemDetails);
router.post("/work-item-details", ctrl.createWorkItemDetail);
router.put("/work-item-details/:id", ctrl.updateWorkItemDetail);
router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
// 전체 저장 (일괄)
router.put("/save-all", ctrl.saveAll);
export default router;

149
db/migrate_company13_export.sh Executable file
View File

@ -0,0 +1,149 @@
#!/bin/bash
# ============================================================
# 엘에스티라유텍(주) - 동부지사 (COMPANY_13) 전체 데이터 Export
#
# 사용법:
# 1. SOURCE_* / TARGET_* 변수를 수정
# 2. chmod +x migrate_company13_export.sh
# 3. ./migrate_company13_export.sh export → SQL 파일 생성
# 4. ./migrate_company13_export.sh import → 대상 DB에 적재
# ============================================================
SOURCE_HOST="localhost"
SOURCE_PORT="5432"
SOURCE_DB="vexplor"
SOURCE_USER="postgres"
TARGET_HOST="대상_호스트"
TARGET_PORT="5432"
TARGET_DB="대상_DB명"
TARGET_USER="postgres"
OUTPUT_FILE="company13_migration_$(date '+%Y%m%d_%H%M%S').sql"
# 데이터가 있는 테이블 (의존성 순서)
TABLES=(
"company_mng"
"user_info"
"authority_master"
"menu_info"
"external_db_connections"
"external_rest_api_connections"
"screen_definitions"
"screen_groups"
"screen_layouts_v1"
"screen_layouts_v2"
"screen_layouts_v3"
"screen_menu_assignments"
"dashboards"
"dashboard_elements"
"flow_definition"
"node_flows"
"table_column_category_values"
"attach_file_info"
"tax_invoice"
"auth_tokens"
"batch_configs"
"batch_execution_logs"
"batch_mappings"
"digital_twin_layout"
"digital_twin_layout_template"
"dtg_management"
"transport_statistics"
"vehicles"
"vehicle_location_history"
)
do_export() {
echo "=========================================="
echo " COMPANY_13 데이터 Export 시작"
echo "=========================================="
cat > "$OUTPUT_FILE" <<'HEADER'
-- ============================================================
-- 엘에스티라유텍() - 동부지사 (COMPANY_13) 전체 데이터 마이그레이션
--
-- 총 29개 테이블, 약 11,500건 데이터
--
-- 실행 방법:
-- psql -h HOST -U USER -d DATABASE -f 이_파일명.sql
-- ============================================================
SET client_encoding TO 'UTF8';
SET standard_conforming_strings = on;
BEGIN;
HEADER
for TABLE in "${TABLES[@]}"; do
COUNT=$(psql -h "$SOURCE_HOST" -p "$SOURCE_PORT" -U "$SOURCE_USER" -d "$SOURCE_DB" \
-t -A -c "SELECT COUNT(*) FROM $TABLE WHERE company_code = 'COMPANY_13'")
COUNT=$(echo "$COUNT" | tr -d '[:space:]')
if [ "$COUNT" -gt 0 ]; then
echo " $TABLE: ${COUNT}건 추출 중..."
echo "-- ----------------------------------------" >> "$OUTPUT_FILE"
echo "-- $TABLE (${COUNT}건)" >> "$OUTPUT_FILE"
echo "-- ----------------------------------------" >> "$OUTPUT_FILE"
echo "COPY $TABLE FROM stdin;" >> "$OUTPUT_FILE"
psql -h "$SOURCE_HOST" -p "$SOURCE_PORT" -U "$SOURCE_USER" -d "$SOURCE_DB" \
-t -A -c "COPY (SELECT * FROM $TABLE WHERE company_code = 'COMPANY_13') TO STDOUT" >> "$OUTPUT_FILE"
echo "\\." >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
else
echo " $TABLE: 데이터 없음 (건너뜀)"
fi
done
echo "" >> "$OUTPUT_FILE"
echo "COMMIT;" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
echo "-- 마이그레이션 완료" >> "$OUTPUT_FILE"
echo ""
echo "=========================================="
echo " Export 완료: $OUTPUT_FILE"
echo "=========================================="
echo ""
echo "대상 DB에서 실행:"
echo " psql -h $TARGET_HOST -p $TARGET_PORT -U $TARGET_USER -d $TARGET_DB -f $OUTPUT_FILE"
}
do_import() {
SQL_FILE=$(ls -t company13_migration_*.sql 2>/dev/null | head -1)
if [ -z "$SQL_FILE" ]; then
echo "마이그레이션 SQL 파일을 찾을 수 없습니다. 먼저 export를 실행하세요."
exit 1
fi
echo "=========================================="
echo " COMPANY_13 데이터 Import 시작"
echo " 파일: $SQL_FILE"
echo " 대상: $TARGET_HOST:$TARGET_PORT/$TARGET_DB"
echo "=========================================="
psql -h "$TARGET_HOST" -p "$TARGET_PORT" -U "$TARGET_USER" -d "$TARGET_DB" -f "$SQL_FILE"
echo ""
echo "=========================================="
echo " Import 완료"
echo "=========================================="
}
case "${1:-export}" in
export)
do_export
;;
import)
do_import
;;
*)
echo "사용법: $0 {export|import}"
exit 1
;;
esac

View File

@ -12,7 +12,7 @@ services:
NODE_ENV: production
PORT: "3001"
HOST: 0.0.0.0
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
JWT_EXPIRES_IN: 24h
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com

View File

@ -0,0 +1,78 @@
# formData 콘솔 로그 수동 테스트 가이드
## 테스트 시나리오
1. http://localhost:9771/screens/1599?menuObjid=1762422235300 접속
2. 로그인 필요 시: `topseal_admin` / `1234`
3. 5초 대기 (페이지 로드)
4. 첫 번째 탭 "공정 마스터" 확인
5. 좌측 패널에서 **P003** 행 클릭
6. 우측 패널에서 **추가** 버튼 클릭
7. 모달에서 설비(equipment) 드롭다운에서 항목 선택
8. **저장** 버튼 클릭 **전** 콘솔 스냅샷 확인
9. **저장** 버튼 클릭 **후** 콘솔 로그 확인
## 확인할 콘솔 로그
### 1. ADD 모드 formData 설정 (ScreenModal)
```
🔵 [ScreenModal] ADD모드 formData 설정: {...}
```
- **위치**: `frontend/components/common/ScreenModal.tsx` 358행
- **의미**: 모달이 ADD 모드로 열릴 때 부모 데이터(splitPanelParentData)로 설정된 초기 formData
- **확인**: `process_code`가 P003으로 포함되어 있는지
### 2. formData 변경 시 (ScreenModal)
```
🟡 [ScreenModal] onFormDataChange: equipment_code → E001 | formData keys: [...] | process_code: P003
```
- **위치**: `frontend/components/common/ScreenModal.tsx` 1184행
- **의미**: 사용자가 설비를 선택할 때마다 발생
- **확인**: `process_code`가 유지되는지, `equipment_code`가 추가되는지
### 3. 저장 시 formData 디버그 (ButtonPrimary)
```
🔴 [ButtonPrimary] 저장 시 formData 디버그: {
propsFormDataKeys: [...],
screenContextFormDataKeys: [...],
effectiveFormDataKeys: [...],
process_code: "P003",
equipment_code: "E001",
fullData: "{...}"
}
```
- **위치**: `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` 1110행
- **의미**: 저장 버튼 클릭 시 실제로 API에 전달되는 formData
- **확인**: `process_code`, `equipment_code`가 모두 포함되어 있는지
## 추가로 확인할 로그
- `process_code` 포함 로그
- `splitPanelParentData` 포함 로그
- `🆕 [추가모달] screenId 기반 모달 열기:` (SplitPanelLayoutComponent 1639행)
## 에러 확인
콘솔에 빨간색으로 표시되는 에러 메시지가 있는지 확인하세요.
## 사전 조건
- **process_mng** 테이블에 P003 데이터가 있어야 함 (company_code = 로그인 사용자 회사)
- **equipment_mng** 테이블에 설비 데이터가 있어야 함
- 로그인 사용자가 해당 회사(COMPANY_7 등) 권한이 있어야 함
## 자동 테스트 스크립트
데이터가 준비된 환경에서:
```bash
cd frontend && npx tsx scripts/test-formdata-logs.ts
```
데이터가 없으면 "좌측 테이블에 데이터가 없습니다" 오류가 발생합니다.

View File

@ -178,10 +178,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
splitPanelParentData,
selectedData: eventSelectedData,
selectedIds,
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
fieldMappings, // 🆕 필드 매핑 정보 (명시적 매핑이 있으면 모든 매핑된 필드 전달)
isCreateMode,
fieldMappings,
} = event.detail;
console.log("🟣 [ScreenModal] openScreenModal 이벤트 수신:", {
screenId,
splitPanelParentData: JSON.stringify(splitPanelParentData),
editData: !!editData,
isCreateMode,
});
// 🆕 모달 열린 시간 기록
modalOpenedAtRef.current = Date.now();
@ -355,8 +362,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
if (Object.keys(parentData).length > 0) {
console.log("🔵 [ScreenModal] ADD모드 formData 설정:", JSON.stringify(parentData));
setFormData(parentData);
} else {
console.log("🔵 [ScreenModal] ADD모드 formData 비어있음");
setFormData({});
}
setOriginalData(null); // 신규 등록 모드
@ -1173,13 +1182,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
formData={formData}
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={(fieldName, value) => {
// 사용자가 실제로 데이터를 변경한 것으로 표시
formDataChangedRef.current = true;
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
console.log("🟡 [ScreenModal] onFormDataChange:", fieldName, "→", value, "| formData keys:", Object.keys(newFormData), "| process_code:", newFormData.process_code);
return newFormData;
});
}}

View File

@ -112,6 +112,7 @@ import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
/**
*

View File

@ -1603,6 +1603,57 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const handleAddClick = useCallback(
(panel: "left" | "right") => {
console.log("🆕 [추가모달] handleAddClick 호출:", { panel, activeTabIndex });
// screenId 기반 모달 확인
const panelConfig = panel === "left" ? componentConfig.leftPanel : componentConfig.rightPanel;
const addModalConfig = panelConfig?.addModal;
if (addModalConfig?.screenId) {
if (panel === "right" && !selectedLeftItem) {
toast({
title: "항목을 선택해주세요",
description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.",
variant: "destructive",
});
return;
}
const tableName = panelConfig?.tableName || "";
const urlParams: Record<string, any> = {
mode: "add",
tableName,
};
const parentData: Record<string, any> = {};
if (panel === "right" && selectedLeftItem) {
const relation = componentConfig.rightPanel?.relation;
console.log("🟢 [추가모달] selectedLeftItem:", JSON.stringify(selectedLeftItem));
console.log("🟢 [추가모달] relation:", JSON.stringify(relation));
if (relation?.keys && Array.isArray(relation.keys)) {
for (const key of relation.keys) {
console.log("🟢 [추가모달] key:", key, "leftValue:", selectedLeftItem[key.leftColumn]);
if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) {
parentData[key.rightColumn] = selectedLeftItem[key.leftColumn];
}
}
}
}
console.log("🆕 [추가모달] screenId 기반 모달 열기:", { screenId: addModalConfig.screenId, tableName, parentData, parentDataStr: JSON.stringify(parentData) });
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId: addModalConfig.screenId,
urlParams,
splitPanelParentData: parentData,
},
}),
);
return;
}
// 기존 인라인 모달 방식
setAddModalPanel(panel);
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움

View File

@ -1107,6 +1107,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
effectiveFormData = { ...splitPanelParentData };
}
console.log("🔴 [ButtonPrimary] 저장 시 formData 디버그:", {
propsFormDataKeys: Object.keys(propsFormData),
screenContextFormDataKeys: Object.keys(screenContextFormData),
effectiveFormDataKeys: Object.keys(effectiveFormData),
process_code: effectiveFormData.process_code,
equipment_code: effectiveFormData.equipment_code,
fullData: JSON.stringify(effectiveFormData),
});
const context: ButtonActionContext = {
formData: effectiveFormData,
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)

View File

@ -0,0 +1,241 @@
"use client";
import React, { useState, useMemo, useCallback } from "react";
import { Save, Loader2, ClipboardCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { ProcessWorkStandardConfig, WorkItem } from "./types";
import { defaultConfig } from "./config";
import { useProcessWorkStandard } from "./hooks/useProcessWorkStandard";
import { ItemProcessSelector } from "./components/ItemProcessSelector";
import { WorkPhaseSection } from "./components/WorkPhaseSection";
import { WorkItemAddModal } from "./components/WorkItemAddModal";
interface ProcessWorkStandardComponentProps {
config?: Partial<ProcessWorkStandardConfig>;
formData?: Record<string, any>;
isPreview?: boolean;
tableName?: string;
}
export function ProcessWorkStandardComponent({
config: configProp,
isPreview,
}: ProcessWorkStandardComponentProps) {
const config: ProcessWorkStandardConfig = useMemo(
() => ({
...defaultConfig,
...configProp,
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
phases: configProp?.phases?.length
? configProp.phases
: defaultConfig.phases,
detailTypes: configProp?.detailTypes?.length
? configProp.detailTypes
: defaultConfig.detailTypes,
}),
[configProp]
);
const {
items,
routings,
workItems,
selectedWorkItemDetails,
selectedWorkItemId,
selection,
loading,
fetchItems,
selectItem,
selectProcess,
fetchWorkItemDetails,
createWorkItem,
updateWorkItem,
deleteWorkItem,
createDetail,
updateDetail,
deleteDetail,
} = useProcessWorkStandard(config);
// 모달 상태
const [modalOpen, setModalOpen] = useState(false);
const [modalPhaseKey, setModalPhaseKey] = useState("");
const [editingItem, setEditingItem] = useState<WorkItem | null>(null);
// phase별 작업 항목 그룹핑
const workItemsByPhase = useMemo(() => {
const map: Record<string, WorkItem[]> = {};
for (const phase of config.phases) {
map[phase.key] = workItems.filter((wi) => wi.work_phase === phase.key);
}
return map;
}, [workItems, config.phases]);
const sortedPhases = useMemo(
() => [...config.phases].sort((a, b) => a.sortOrder - b.sortOrder),
[config.phases]
);
const handleAddWorkItem = useCallback((phaseKey: string) => {
setModalPhaseKey(phaseKey);
setEditingItem(null);
setModalOpen(true);
}, []);
const handleEditWorkItem = useCallback((item: WorkItem) => {
setModalPhaseKey(item.work_phase);
setEditingItem(item);
setModalOpen(true);
}, []);
const handleModalSave = useCallback(
async (data: Parameters<typeof createWorkItem>[0]) => {
if (editingItem) {
await updateWorkItem(editingItem.id, {
title: data.title,
is_required: data.is_required,
description: data.description,
} as any);
} else {
await createWorkItem(data);
}
},
[editingItem, createWorkItem, updateWorkItem]
);
const handleSelectWorkItem = useCallback(
(workItemId: string) => {
fetchWorkItemDetails(workItemId);
},
[fetchWorkItemDetails]
);
const handleInit = useCallback(() => {
fetchItems();
}, [fetchItems]);
const splitRatio = config.splitRatio || 30;
if (isPreview) {
return (
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/10 p-4">
<div className="text-center">
<ClipboardCheck className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm font-medium text-muted-foreground">
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
{sortedPhases.map((p) => p.label).join(" / ")}
</p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-background">
{/* 메인 콘텐츠 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측 패널 */}
<div style={{ width: `${splitRatio}%` }} className="shrink-0 overflow-hidden">
<ItemProcessSelector
title={config.leftPanelTitle || "품목 및 공정 선택"}
items={items}
routings={routings}
selection={selection}
onSearch={(keyword) => fetchItems(keyword)}
onSelectItem={selectItem}
onSelectProcess={selectProcess}
onInit={handleInit}
/>
</div>
{/* 우측 패널 */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* 우측 헤더 */}
{selection.routingDetailId ? (
<>
<div className="flex items-center justify-between border-b px-4 py-2.5">
<div>
<h2 className="text-base font-bold">
{selection.itemName} - {selection.processName}
</h2>
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span>: {selection.itemCode}</span>
<span>: {selection.processName}</span>
<span>: {selection.routingVersionName}</span>
</div>
</div>
{!config.readonly && (
<Button
variant="default"
size="sm"
className="gap-1.5"
disabled={loading}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
{/* 작업 단계별 섹션 */}
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{sortedPhases.map((phase) => (
<WorkPhaseSection
key={phase.key}
phase={phase}
items={workItemsByPhase[phase.key] || []}
selectedWorkItemId={selectedWorkItemId}
selectedWorkItemDetails={selectedWorkItemDetails}
detailTypes={config.detailTypes}
readonly={config.readonly}
onSelectWorkItem={handleSelectWorkItem}
onAddWorkItem={handleAddWorkItem}
onEditWorkItem={handleEditWorkItem}
onDeleteWorkItem={deleteWorkItem}
onCreateDetail={createDetail}
onUpdateDetail={updateDetail}
onDeleteDetail={deleteDetail}
/>
))}
</div>
</>
) : (
<div className="flex flex-1 flex-col items-center justify-center text-center">
<ClipboardCheck className="mb-3 h-12 w-12 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
</p>
</div>
)}
</div>
</div>
{/* 작업 항목 추가/수정 모달 */}
<WorkItemAddModal
open={modalOpen}
onClose={() => {
setModalOpen(false);
setEditingItem(null);
}}
onSave={handleModalSave}
phaseKey={modalPhaseKey}
phaseLabel={
config.phases.find((p) => p.key === modalPhaseKey)?.label || ""
}
detailTypes={config.detailTypes}
editItem={editingItem}
/>
</div>
);
}

View File

@ -0,0 +1,282 @@
"use client";
import React from "react";
import { Plus, Trash2, GripVertical } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { ProcessWorkStandardConfig, WorkPhaseDefinition, DetailTypeDefinition } from "./types";
import { defaultConfig } from "./config";
interface ConfigPanelProps {
config: Partial<ProcessWorkStandardConfig>;
onChange: (config: Partial<ProcessWorkStandardConfig>) => void;
}
export function ProcessWorkStandardConfigPanel({
config: configProp,
onChange,
}: ConfigPanelProps) {
const config: ProcessWorkStandardConfig = {
...defaultConfig,
...configProp,
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
phases: configProp?.phases?.length ? configProp.phases : defaultConfig.phases,
detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes,
};
const update = (partial: Partial<ProcessWorkStandardConfig>) => {
onChange({ ...configProp, ...partial });
};
const updateDataSource = (field: string, value: string) => {
update({
dataSource: { ...config.dataSource, [field]: value },
});
};
// 작업 단계 관리
const addPhase = () => {
const nextOrder = config.phases.length + 1;
update({
phases: [
...config.phases,
{ key: `PHASE_${nextOrder}`, label: `단계 ${nextOrder}`, sortOrder: nextOrder },
],
});
};
const removePhase = (idx: number) => {
update({ phases: config.phases.filter((_, i) => i !== idx) });
};
const updatePhase = (idx: number, field: keyof WorkPhaseDefinition, value: string | number) => {
const next = [...config.phases];
next[idx] = { ...next[idx], [field]: value };
update({ phases: next });
};
// 상세 유형 관리
const addDetailType = () => {
update({
detailTypes: [
...config.detailTypes,
{ value: `TYPE_${config.detailTypes.length + 1}`, label: "신규 유형" },
],
});
};
const removeDetailType = (idx: number) => {
update({ detailTypes: config.detailTypes.filter((_, i) => i !== idx) });
};
const updateDetailType = (idx: number, field: keyof DetailTypeDefinition, value: string) => {
const next = [...config.detailTypes];
next[idx] = { ...next[idx], [field]: value };
update({ detailTypes: next });
};
return (
<div className="space-y-5 p-4">
<h3 className="text-sm font-semibold"> </h3>
{/* 데이터 소스 설정 */}
<section className="space-y-3">
<p className="text-xs font-medium text-muted-foreground"> </p>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.itemTable}
onChange={(e) => updateDataSource("itemTable", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.itemNameColumn}
onChange={(e) => updateDataSource("itemNameColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.itemCodeColumn}
onChange={(e) => updateDataSource("itemCodeColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.routingVersionTable}
onChange={(e) => updateDataSource("routingVersionTable", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> FK </Label>
<Input
value={config.dataSource.routingFkColumn}
onChange={(e) => updateDataSource("routingFkColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.processTable}
onChange={(e) => updateDataSource("processTable", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.processNameColumn}
onChange={(e) => updateDataSource("processNameColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.dataSource.processCodeColumn}
onChange={(e) => updateDataSource("processCodeColumn", e.target.value)}
className="mt-1 h-8 text-xs"
/>
</div>
</div>
</section>
{/* 작업 단계 설정 */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground"> </p>
<Button variant="outline" size="sm" className="h-6 gap-1 text-[10px]" onClick={addPhase}>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="space-y-1.5">
{config.phases.map((phase, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 rounded border bg-muted/30 p-1.5"
>
<GripVertical className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
<Input
value={phase.key}
onChange={(e) => updatePhase(idx, "key", e.target.value)}
className="h-7 w-20 text-[10px]"
placeholder="키"
/>
<Input
value={phase.label}
onChange={(e) => updatePhase(idx, "label", e.target.value)}
className="h-7 flex-1 text-[10px]"
placeholder="표시명"
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-destructive hover:text-destructive"
onClick={() => removePhase(idx)}
disabled={config.phases.length <= 1}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</section>
{/* 상세 유형 옵션 */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground"> </p>
<Button
variant="outline"
size="sm"
className="h-6 gap-1 text-[10px]"
onClick={addDetailType}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="space-y-1.5">
{config.detailTypes.map((dt, idx) => (
<div key={idx} className="flex items-center gap-1.5 rounded border bg-muted/30 p-1.5">
<Input
value={dt.value}
onChange={(e) => updateDetailType(idx, "value", e.target.value)}
className="h-7 w-24 text-[10px]"
placeholder="값"
/>
<Input
value={dt.label}
onChange={(e) => updateDetailType(idx, "label", e.target.value)}
className="h-7 flex-1 text-[10px]"
placeholder="표시명"
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-destructive hover:text-destructive"
onClick={() => removeDetailType(idx)}
disabled={config.detailTypes.length <= 1}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</section>
{/* UI 설정 */}
<section className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">UI </p>
<div>
<Label className="text-xs"> (%)</Label>
<Input
type="number"
value={config.splitRatio || 30}
onChange={(e) => update({ splitRatio: Number(e.target.value) })}
min={15}
max={50}
className="mt-1 h-8 w-20 text-xs"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={config.leftPanelTitle || ""}
onChange={(e) => update({ leftPanelTitle: e.target.value })}
className="mt-1 h-8 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={config.readonly || false}
onCheckedChange={(v) => update({ readonly: v })}
/>
<Label className="text-xs"> </Label>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2ProcessWorkStandardDefinition } from "./index";
import { ProcessWorkStandardComponent } from "./ProcessWorkStandardComponent";
export class ProcessWorkStandardRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2ProcessWorkStandardDefinition;
render(): React.ReactElement {
const { formData, isPreview, config, tableName } = this.props as Record<
string,
unknown
>;
return (
<ProcessWorkStandardComponent
config={(config as object) || {}}
formData={formData as Record<string, unknown>}
tableName={tableName as string}
isPreview={isPreview as boolean}
/>
);
}
}
ProcessWorkStandardRenderer.registerSelf();
if (process.env.NODE_ENV === "development") {
ProcessWorkStandardRenderer.enableHotReload();
}

View File

@ -0,0 +1,167 @@
"use client";
import React, { useEffect, useState } from "react";
import { Search, ChevronDown, ChevronRight, Package, GitBranch, Settings2, Star } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { ItemData, RoutingVersion, SelectionState } from "../types";
interface ItemProcessSelectorProps {
title: string;
items: ItemData[];
routings: RoutingVersion[];
selection: SelectionState;
onSearch: (keyword: string) => void;
onSelectItem: (itemCode: string, itemName: string) => void;
onSelectProcess: (
routingDetailId: string,
processName: string,
routingVersionId: string,
routingVersionName: string
) => void;
onInit: () => void;
}
export function ItemProcessSelector({
title,
items,
routings,
selection,
onSearch,
onSelectItem,
onSelectProcess,
onInit,
}: ItemProcessSelectorProps) {
const [searchKeyword, setSearchKeyword] = useState("");
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
useEffect(() => {
onInit();
}, [onInit]);
const handleSearch = (value: string) => {
setSearchKeyword(value);
onSearch(value);
};
const toggleItem = (itemCode: string, itemName: string) => {
const next = new Set(expandedItems);
if (next.has(itemCode)) {
next.delete(itemCode);
} else {
next.add(itemCode);
onSelectItem(itemCode, itemName);
}
setExpandedItems(next);
};
const isItemExpanded = (itemCode: string) => expandedItems.has(itemCode);
return (
<div className="flex h-full flex-col border-r bg-background">
{/* 헤더 */}
<div className="border-b p-3">
<div className="mb-2 flex items-center gap-2">
<Package className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold">{title}</span>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="품목 또는 공정 검색"
value={searchKeyword}
onChange={(e) => handleSearch(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
</div>
{/* 트리 목록 */}
<div className="flex-1 overflow-y-auto p-2">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Package className="mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">
</p>
</div>
) : (
items.map((item) => (
<div key={item.item_code} className="mb-1">
{/* 품목 헤더 */}
<button
onClick={() => toggleItem(item.item_code, item.item_name)}
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
"hover:bg-accent",
selection.itemCode === item.item_code && "bg-accent"
)}
>
{isItemExpanded(item.item_code) ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<Package className="h-3.5 w-3.5 shrink-0 text-primary" />
<span className="truncate font-medium">
{item.item_name} ({item.item_code})
</span>
</button>
{/* 라우팅 + 공정 */}
{isItemExpanded(item.item_code) &&
selection.itemCode === item.item_code && (
<div className="ml-4 mt-0.5 border-l pl-2">
{routings.length === 0 ? (
<p className="py-2 text-[10px] text-muted-foreground">
</p>
) : (
routings.map((routing) => (
<div key={routing.id} className="mb-1">
{/* 라우팅 버전 */}
<div className="flex items-center gap-1.5 px-1 py-1">
<Star className="h-3 w-3 text-amber-500" />
<span className="text-[11px] font-medium text-muted-foreground">
{routing.version_name || "기본 라우팅"}
</span>
</div>
{/* 공정 목록 */}
{routing.processes.map((proc) => (
<button
key={proc.routing_detail_id}
onClick={() =>
onSelectProcess(
proc.routing_detail_id,
proc.process_name,
routing.id,
routing.version_name || "기본 라우팅"
)
}
className={cn(
"ml-3 flex w-[calc(100%-12px)] items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
"hover:bg-primary/10",
selection.routingDetailId ===
proc.routing_detail_id &&
"bg-primary/15 font-semibold text-primary"
)}
>
<Settings2 className="h-3 w-3 shrink-0" />
<span className="truncate">
{proc.process_name || proc.process_code}
</span>
</button>
))}
</div>
))
)}
</div>
)}
</div>
))
)}
</div>
</div>
);
}

View File

@ -0,0 +1,337 @@
"use client";
import React, { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DetailTypeDefinition, WorkItem } from "../types";
interface ModalDetail {
id: string;
detail_type: string;
content: string;
is_required: string;
sort_order: number;
}
interface WorkItemAddModalProps {
open: boolean;
onClose: () => void;
onSave: (data: {
work_phase: string;
title: string;
is_required: string;
description?: string;
details?: Array<{
detail_type?: string;
content: string;
is_required: string;
sort_order: number;
}>;
}) => void;
phaseKey: string;
phaseLabel: string;
detailTypes: DetailTypeDefinition[];
editItem?: WorkItem | null;
}
export function WorkItemAddModal({
open,
onClose,
onSave,
phaseKey,
phaseLabel,
detailTypes,
editItem,
}: WorkItemAddModalProps) {
const [title, setTitle] = useState(editItem?.title || "");
const [isRequired, setIsRequired] = useState(editItem?.is_required || "Y");
const [description, setDescription] = useState(editItem?.description || "");
const [details, setDetails] = useState<ModalDetail[]>([]);
const resetForm = () => {
setTitle("");
setIsRequired("Y");
setDescription("");
setDetails([]);
};
const handleSave = () => {
if (!title.trim()) return;
onSave({
work_phase: phaseKey,
title: title.trim(),
is_required: isRequired,
description: description.trim() || undefined,
details: details
.filter((d) => d.content.trim())
.map((d, idx) => ({
detail_type: d.detail_type || undefined,
content: d.content.trim(),
is_required: d.is_required,
sort_order: idx + 1,
})),
});
resetForm();
onClose();
};
const addDetail = () => {
setDetails((prev) => [
...prev,
{
id: crypto.randomUUID(),
detail_type: detailTypes[0]?.value || "",
content: "",
is_required: "N",
sort_order: prev.length + 1,
},
]);
};
const removeDetail = (id: string) => {
setDetails((prev) => prev.filter((d) => d.id !== id));
};
const updateDetailField = (
id: string,
field: keyof ModalDetail,
value: string | number
) => {
setDetails((prev) =>
prev.map((d) => (d.id === id ? { ...d, [field]: value } : d))
);
};
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) {
resetForm();
onClose();
}
}}
>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editItem ? "수정" : "추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{phaseLabel} {editItem ? "항목을 수정" : "새 항목을 추가"}.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 기본 정보 */}
<div className="space-y-3 rounded-lg border p-3">
<p className="text-xs font-medium text-muted-foreground">
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">
<span className="text-destructive">*</span>
</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 장비 점검, 품질 검사"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Select value={isRequired} onValueChange={setIsRequired}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y" className="text-xs sm:text-sm">
</SelectItem>
<SelectItem value="N" className="text-xs sm:text-sm">
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs"></Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="추가 설명이나 주의사항"
className="mt-1 min-h-[60px] text-xs sm:text-sm"
rows={2}
/>
</div>
</div>
{/* 상세 항목 (신규 추가 시에만) */}
{!editItem && (
<div className="space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
</p>
<Button
variant="outline"
size="sm"
className="h-6 gap-1 text-[10px]"
onClick={addDetail}
>
<Plus className="h-3 w-3" />
</Button>
</div>
{details.length > 0 ? (
<table className="w-full text-xs">
<thead>
<tr className="border-b">
<th className="w-10 py-1.5 text-center font-medium text-muted-foreground">
</th>
<th className="w-20 px-1 py-1.5 text-left font-medium text-muted-foreground">
</th>
<th className="px-1 py-1.5 text-left font-medium text-muted-foreground">
</th>
<th className="w-16 px-1 py-1.5 text-center font-medium text-muted-foreground">
</th>
<th className="w-10 py-1.5 text-center font-medium text-muted-foreground">
</th>
</tr>
</thead>
<tbody>
{details.map((detail, idx) => (
<tr key={detail.id} className="border-b">
<td className="py-1 text-center text-muted-foreground">
{idx + 1}
</td>
<td className="px-1 py-1">
<Select
value={detail.detail_type}
onValueChange={(v) =>
updateDetailField(detail.id, "detail_type", v)
}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{detailTypes.map((t) => (
<SelectItem
key={t.value}
value={t.value}
className="text-xs"
>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-1 py-1">
<Input
value={detail.content}
onChange={(e) =>
updateDetailField(
detail.id,
"content",
e.target.value
)
}
placeholder="상세 내용"
className="h-7 text-[10px]"
/>
</td>
<td className="px-1 py-1">
<Select
value={detail.is_required}
onValueChange={(v) =>
updateDetailField(detail.id, "is_required", v)
}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y" className="text-xs">
</SelectItem>
<SelectItem value="N" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</td>
<td className="py-1 text-center">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => removeDetail(detail.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="py-3 text-center text-[10px] text-muted-foreground">
. &quot; &quot; .
</p>
)}
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
resetForm();
onClose();
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={!title.trim()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,88 @@
"use client";
import React from "react";
import { GripVertical, Pencil, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { WorkItem } from "../types";
interface WorkItemCardProps {
item: WorkItem;
isSelected: boolean;
readonly?: boolean;
onClick: () => void;
onEdit: () => void;
onDelete: () => void;
}
export function WorkItemCard({
item,
isSelected,
readonly,
onClick,
onEdit,
onDelete,
}: WorkItemCardProps) {
return (
<div
onClick={onClick}
className={cn(
"group flex cursor-pointer items-start gap-2 rounded-lg border p-3 transition-all",
"hover:border-primary/30 hover:shadow-sm",
isSelected
? "border-primary bg-primary/5 shadow-sm"
: "border-border bg-card"
)}
>
<GripVertical className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground/50" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{item.title}</span>
</div>
<div className="mt-1 flex items-center gap-2">
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] font-normal"
>
{item.detail_count}
</Badge>
<Badge
variant={item.is_required === "Y" ? "default" : "outline"}
className="h-5 px-1.5 text-[10px] font-normal"
>
{item.is_required === "Y" ? "필수" : "선택"}
</Badge>
</div>
</div>
{!readonly && (
<div className="flex shrink-0 gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,380 @@
"use client";
import React, { useState } from "react";
import { Plus, Pencil, Trash2, Check, X, HandMetal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { WorkItem, WorkItemDetail, DetailTypeDefinition } from "../types";
interface WorkItemDetailListProps {
workItem: WorkItem | null;
details: WorkItemDetail[];
detailTypes: DetailTypeDefinition[];
readonly?: boolean;
onCreateDetail: (data: Partial<WorkItemDetail>) => void;
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
onDeleteDetail: (id: string) => void;
}
export function WorkItemDetailList({
workItem,
details,
detailTypes,
readonly,
onCreateDetail,
onUpdateDetail,
onDeleteDetail,
}: WorkItemDetailListProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [editData, setEditData] = useState<Partial<WorkItemDetail>>({});
const [isAdding, setIsAdding] = useState(false);
const [newData, setNewData] = useState<Partial<WorkItemDetail>>({
detail_type: detailTypes[0]?.value || "",
content: "",
is_required: "N",
sort_order: 0,
});
if (!workItem) {
return (
<div className="flex h-full flex-col items-center justify-center text-center">
<HandMetal className="mb-2 h-10 w-10 text-amber-400" />
<p className="text-sm text-muted-foreground">
</p>
</div>
);
}
const getTypeLabel = (value?: string) =>
detailTypes.find((t) => t.value === value)?.label || value || "-";
const handleAdd = () => {
if (!newData.content?.trim()) return;
onCreateDetail({
...newData,
sort_order: details.length + 1,
});
setNewData({
detail_type: detailTypes[0]?.value || "",
content: "",
is_required: "N",
sort_order: 0,
});
setIsAdding(false);
};
const handleSaveEdit = (id: string) => {
onUpdateDetail(id, editData);
setEditingId(null);
setEditData({});
};
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{workItem.title}</span>
<Badge variant="secondary" className="text-[10px]">
{details.length}
</Badge>
</div>
{!readonly && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => setIsAdding(true)}
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
{/* 테이블 */}
<div className="flex-1 overflow-y-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-muted/50">
<tr className="border-b">
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground">
</th>
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground">
</th>
<th className="px-2 py-2 text-left font-medium text-muted-foreground">
</th>
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground">
</th>
{!readonly && (
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground">
</th>
)}
</tr>
</thead>
<tbody>
{details.map((detail, idx) => (
<tr
key={detail.id}
className="border-b transition-colors hover:bg-muted/30"
>
{editingId === detail.id ? (
<>
<td className="px-2 py-1.5 text-center">{idx + 1}</td>
<td className="px-2 py-1.5">
<Select
value={editData.detail_type || detail.detail_type || ""}
onValueChange={(v) =>
setEditData((prev) => ({
...prev,
detail_type: v,
}))
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{detailTypes.map((t) => (
<SelectItem
key={t.value}
value={t.value}
className="text-xs"
>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-2 py-1.5">
<Input
value={editData.content ?? detail.content}
onChange={(e) =>
setEditData((prev) => ({
...prev,
content: e.target.value,
}))
}
className="h-7 text-xs"
/>
</td>
<td className="px-2 py-1.5 text-center">
<Select
value={editData.is_required ?? detail.is_required}
onValueChange={(v) =>
setEditData((prev) => ({
...prev,
is_required: v,
}))
}
>
<SelectTrigger className="h-7 w-14 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y" className="text-xs">
</SelectItem>
<SelectItem value="N" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</td>
<td className="px-2 py-1.5 text-center">
<div className="flex justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-green-600"
onClick={() => handleSaveEdit(detail.id)}
>
<Check className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
setEditingId(null);
setEditData({});
}}
>
<X className="h-3 w-3" />
</Button>
</div>
</td>
</>
) : (
<>
<td className="px-2 py-1.5 text-center text-muted-foreground">
{idx + 1}
</td>
<td className="px-2 py-1.5">
<Badge
variant="outline"
className="text-[10px] font-normal"
>
{getTypeLabel(detail.detail_type)}
</Badge>
</td>
<td className="px-2 py-1.5">{detail.content}</td>
<td className="px-2 py-1.5 text-center">
<Badge
variant={
detail.is_required === "Y" ? "default" : "secondary"
}
className="text-[10px] font-normal"
>
{detail.is_required === "Y" ? "필수" : "선택"}
</Badge>
</td>
{!readonly && (
<td className="px-2 py-1.5 text-center">
<div className="flex justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
setEditingId(detail.id);
setEditData({
detail_type: detail.detail_type,
content: detail.content,
is_required: detail.is_required,
});
}}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => onDeleteDetail(detail.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</td>
)}
</>
)}
</tr>
))}
{/* 추가 행 */}
{isAdding && (
<tr className="border-b bg-primary/5">
<td className="px-2 py-1.5 text-center text-muted-foreground">
{details.length + 1}
</td>
<td className="px-2 py-1.5">
<Select
value={newData.detail_type || ""}
onValueChange={(v) =>
setNewData((prev) => ({ ...prev, detail_type: v }))
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{detailTypes.map((t) => (
<SelectItem
key={t.value}
value={t.value}
className="text-xs"
>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-2 py-1.5">
<Input
autoFocus
placeholder="상세 내용 입력"
value={newData.content || ""}
onChange={(e) =>
setNewData((prev) => ({
...prev,
content: e.target.value,
}))
}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
className="h-7 text-xs"
/>
</td>
<td className="px-2 py-1.5 text-center">
<Select
value={newData.is_required || "N"}
onValueChange={(v) =>
setNewData((prev) => ({ ...prev, is_required: v }))
}
>
<SelectTrigger className="h-7 w-14 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y" className="text-xs">
</SelectItem>
<SelectItem value="N" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</td>
<td className="px-2 py-1.5 text-center">
<div className="flex justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-green-600"
onClick={handleAdd}
>
<Check className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setIsAdding(false)}
>
<X className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
)}
</tbody>
</table>
{details.length === 0 && !isAdding && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-xs text-muted-foreground">
. &quot; &quot; .
</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,123 @@
"use client";
import React from "react";
import { Plus, ClipboardList } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { WorkItemCard } from "./WorkItemCard";
import { WorkItemDetailList } from "./WorkItemDetailList";
import {
WorkItem,
WorkItemDetail,
WorkPhaseDefinition,
DetailTypeDefinition,
} from "../types";
interface WorkPhaseSectionProps {
phase: WorkPhaseDefinition;
items: WorkItem[];
selectedWorkItemId: string | null;
selectedWorkItemDetails: WorkItemDetail[];
detailTypes: DetailTypeDefinition[];
readonly?: boolean;
onSelectWorkItem: (workItemId: string) => void;
onAddWorkItem: (phase: string) => void;
onEditWorkItem: (item: WorkItem) => void;
onDeleteWorkItem: (id: string) => void;
onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>) => void;
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
onDeleteDetail: (id: string) => void;
}
export function WorkPhaseSection({
phase,
items,
selectedWorkItemId,
selectedWorkItemDetails,
detailTypes,
readonly,
onSelectWorkItem,
onAddWorkItem,
onEditWorkItem,
onDeleteWorkItem,
onCreateDetail,
onUpdateDetail,
onDeleteDetail,
}: WorkPhaseSectionProps) {
const selectedItem = items.find((i) => i.id === selectedWorkItemId) || null;
const isThisSectionSelected = items.some(
(i) => i.id === selectedWorkItemId
);
return (
<div className="rounded-lg border bg-card">
{/* 섹션 헤더 */}
<div className="flex items-center justify-between border-b px-4 py-2.5">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{phase.label}</h3>
<Badge
variant="secondary"
className="h-5 rounded-full px-2 text-[10px]"
>
{items.length}
</Badge>
</div>
{!readonly && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => onAddWorkItem(phase.key)}
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
{/* 콘텐츠 영역 */}
<div className="flex min-h-[140px]">
{/* 좌측: 작업 항목 카드 목록 */}
<div className="w-[240px] shrink-0 border-r p-2">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<ClipboardList className="mb-1 h-6 w-6 text-muted-foreground/40" />
<p className="text-[11px] text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-1.5">
{items.map((item) => (
<WorkItemCard
key={item.id}
item={item}
isSelected={selectedWorkItemId === item.id}
readonly={readonly}
onClick={() => onSelectWorkItem(item.id)}
onEdit={() => onEditWorkItem(item)}
onDelete={() => onDeleteWorkItem(item.id)}
/>
))}
</div>
)}
</div>
{/* 우측: 상세 리스트 */}
<div className="flex-1">
<WorkItemDetailList
workItem={isThisSectionSelected ? selectedItem : null}
details={isThisSectionSelected ? selectedWorkItemDetails : []}
detailTypes={detailTypes}
readonly={readonly}
onCreateDetail={(data) =>
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data)
}
onUpdateDetail={onUpdateDetail}
onDeleteDetail={onDeleteDetail}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
/**
*
*/
import { ProcessWorkStandardConfig } from "./types";
export const defaultConfig: ProcessWorkStandardConfig = {
dataSource: {
itemTable: "item_info",
itemNameColumn: "item_name",
itemCodeColumn: "item_number",
routingVersionTable: "item_routing_version",
routingFkColumn: "item_code",
routingVersionNameColumn: "version_name",
routingDetailTable: "item_routing_detail",
processTable: "process_mng",
processNameColumn: "process_name",
processCodeColumn: "process_code",
},
phases: [
{ key: "PRE", label: "작업 전 (Pre-Work)", sortOrder: 1 },
{ key: "IN", label: "작업 중 (In-Work)", sortOrder: 2 },
{ key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
],
detailTypes: [
{ value: "CHECK", label: "체크" },
{ value: "INSPECTION", label: "검사" },
{ value: "MEASUREMENT", label: "측정" },
],
splitRatio: 30,
leftPanelTitle: "품목 및 공정 선택",
readonly: false,
};

View File

@ -0,0 +1,336 @@
"use client";
import { useState, useCallback } from "react";
import { apiClient } from "@/lib/api/client";
import {
ProcessWorkStandardConfig,
ItemData,
RoutingVersion,
WorkItem,
WorkItemDetail,
SelectionState,
} from "../types";
const API_BASE = "/api/process-work-standard";
export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
const [items, setItems] = useState<ItemData[]>([]);
const [routings, setRoutings] = useState<RoutingVersion[]>([]);
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
const [selectedWorkItemDetails, setSelectedWorkItemDetails] = useState<WorkItemDetail[]>([]);
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [selection, setSelection] = useState<SelectionState>({
itemCode: null,
itemName: null,
routingVersionId: null,
routingVersionName: null,
routingDetailId: null,
processName: null,
});
// 품목 목록 조회
const fetchItems = useCallback(
async (search?: string) => {
try {
setLoading(true);
const ds = config.dataSource;
const params = new URLSearchParams({
tableName: ds.itemTable,
nameColumn: ds.itemNameColumn,
codeColumn: ds.itemCodeColumn,
routingTable: ds.routingVersionTable,
routingFkColumn: ds.routingFkColumn,
...(search ? { search } : {}),
});
const res = await apiClient.get(`${API_BASE}/items?${params}`);
if (res.data?.success) {
setItems(res.data.data);
}
} catch (err) {
console.error("품목 조회 실패", err);
} finally {
setLoading(false);
}
},
[config.dataSource]
);
// 라우팅 + 공정 조회
const fetchRoutings = useCallback(
async (itemCode: string) => {
try {
const ds = config.dataSource;
const params = new URLSearchParams({
routingVersionTable: ds.routingVersionTable,
routingDetailTable: ds.routingDetailTable,
routingFkColumn: ds.routingFkColumn,
processTable: ds.processTable,
processNameColumn: ds.processNameColumn,
processCodeColumn: ds.processCodeColumn,
});
const res = await apiClient.get(
`${API_BASE}/items/${encodeURIComponent(itemCode)}/routings?${params}`
);
if (res.data?.success) {
setRoutings(res.data.data);
}
} catch (err) {
console.error("라우팅 조회 실패", err);
}
},
[config.dataSource]
);
// 작업 항목 조회
const fetchWorkItems = useCallback(async (routingDetailId: string) => {
try {
setLoading(true);
const res = await apiClient.get(
`${API_BASE}/routing-detail/${routingDetailId}/work-items`
);
if (res.data?.success) {
setWorkItems(res.data.items || []);
}
} catch (err) {
console.error("작업 항목 조회 실패", err);
} finally {
setLoading(false);
}
}, []);
// 작업 항목 상세 조회
const fetchWorkItemDetails = useCallback(async (workItemId: string) => {
try {
const res = await apiClient.get(
`${API_BASE}/work-items/${workItemId}/details`
);
if (res.data?.success) {
setSelectedWorkItemDetails(res.data.data);
setSelectedWorkItemId(workItemId);
}
} catch (err) {
console.error("상세 조회 실패", err);
}
}, []);
// 품목 선택
const selectItem = useCallback(
async (itemCode: string, itemName: string) => {
setSelection((prev) => ({
...prev,
itemCode,
itemName,
routingVersionId: null,
routingVersionName: null,
routingDetailId: null,
processName: null,
}));
setWorkItems([]);
setSelectedWorkItemDetails([]);
setSelectedWorkItemId(null);
await fetchRoutings(itemCode);
},
[fetchRoutings]
);
// 공정 선택
const selectProcess = useCallback(
async (
routingDetailId: string,
processName: string,
routingVersionId: string,
routingVersionName: string
) => {
setSelection((prev) => ({
...prev,
routingVersionId,
routingVersionName,
routingDetailId,
processName,
}));
setSelectedWorkItemDetails([]);
setSelectedWorkItemId(null);
await fetchWorkItems(routingDetailId);
},
[fetchWorkItems]
);
// 작업 항목 추가
const createWorkItem = useCallback(
async (data: {
work_phase: string;
title: string;
is_required: string;
description?: string;
details?: Array<{
detail_type?: string;
content: string;
is_required: string;
sort_order: number;
}>;
}) => {
if (!selection.routingDetailId) return null;
try {
const nextOrder =
workItems.filter((wi) => wi.work_phase === data.work_phase).length + 1;
const res = await apiClient.post(`${API_BASE}/work-items`, {
routing_detail_id: selection.routingDetailId,
work_phase: data.work_phase,
title: data.title,
is_required: data.is_required,
sort_order: nextOrder,
description: data.description,
});
if (res.data?.success && res.data.data) {
const newItem = res.data.data;
// 상세 항목도 함께 생성
if (data.details && data.details.length > 0) {
for (const detail of data.details) {
await apiClient.post(`${API_BASE}/work-item-details`, {
work_item_id: newItem.id,
...detail,
});
}
}
await fetchWorkItems(selection.routingDetailId);
return newItem;
}
} catch (err) {
console.error("작업 항목 생성 실패", err);
}
return null;
},
[selection.routingDetailId, workItems, fetchWorkItems]
);
// 작업 항목 수정
const updateWorkItem = useCallback(
async (id: string, data: Partial<WorkItem>) => {
try {
const res = await apiClient.put(`${API_BASE}/work-items/${id}`, data);
if (res.data?.success && selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId);
}
} catch (err) {
console.error("작업 항목 수정 실패", err);
}
},
[selection.routingDetailId, fetchWorkItems]
);
// 작업 항목 삭제
const deleteWorkItem = useCallback(
async (id: string) => {
try {
const res = await apiClient.delete(`${API_BASE}/work-items/${id}`);
if (res.data?.success && selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId);
if (selectedWorkItemId === id) {
setSelectedWorkItemDetails([]);
setSelectedWorkItemId(null);
}
}
} catch (err) {
console.error("작업 항목 삭제 실패", err);
}
},
[selection.routingDetailId, selectedWorkItemId, fetchWorkItems]
);
// 상세 추가
const createDetail = useCallback(
async (workItemId: string, data: Partial<WorkItemDetail>) => {
try {
const res = await apiClient.post(`${API_BASE}/work-item-details`, {
work_item_id: workItemId,
...data,
});
if (res.data?.success) {
await fetchWorkItemDetails(workItemId);
if (selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId);
}
}
} catch (err) {
console.error("상세 생성 실패", err);
}
},
[fetchWorkItemDetails, fetchWorkItems, selection.routingDetailId]
);
// 상세 수정
const updateDetail = useCallback(
async (id: string, data: Partial<WorkItemDetail>) => {
try {
const res = await apiClient.put(
`${API_BASE}/work-item-details/${id}`,
data
);
if (res.data?.success && selectedWorkItemId) {
await fetchWorkItemDetails(selectedWorkItemId);
}
} catch (err) {
console.error("상세 수정 실패", err);
}
},
[selectedWorkItemId, fetchWorkItemDetails]
);
// 상세 삭제
const deleteDetail = useCallback(
async (id: string) => {
try {
const res = await apiClient.delete(
`${API_BASE}/work-item-details/${id}`
);
if (res.data?.success) {
if (selectedWorkItemId) {
await fetchWorkItemDetails(selectedWorkItemId);
}
if (selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId);
}
}
} catch (err) {
console.error("상세 삭제 실패", err);
}
},
[
selectedWorkItemId,
selection.routingDetailId,
fetchWorkItemDetails,
fetchWorkItems,
]
);
return {
items,
routings,
workItems,
selectedWorkItemDetails,
selectedWorkItemId,
selection,
loading,
saving,
fetchItems,
selectItem,
selectProcess,
fetchWorkItems,
fetchWorkItemDetails,
setSelectedWorkItemId,
createWorkItem,
updateWorkItem,
deleteWorkItem,
createDetail,
updateDetail,
deleteDetail,
};
}

View File

@ -0,0 +1,59 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { ProcessWorkStandardComponent } from "./ProcessWorkStandardComponent";
import { ProcessWorkStandardConfigPanel } from "./ProcessWorkStandardConfigPanel";
import { defaultConfig } from "./config";
export const V2ProcessWorkStandardDefinition = createComponentDefinition({
id: "v2-process-work-standard",
name: "공정 작업기준",
nameEng: "Process Work Standard",
description: "품목별 라우팅/공정에 대한 작업 전·중·후 기준 항목을 관리하는 컴포넌트",
category: ComponentCategory.INPUT,
webType: "component",
component: ProcessWorkStandardComponent,
defaultConfig: defaultConfig,
defaultSize: {
width: 1400,
height: 800,
gridColumnSpan: "12",
},
configPanel: ProcessWorkStandardConfigPanel,
icon: "ClipboardCheck",
tags: ["공정", "작업기준", "품질", "검사", "체크리스트", "라우팅", "제조"],
version: "1.0.0",
author: "개발팀",
documentation: `
(Pre-Work / In-Work / Post-Work) .
##
- 좌측: 품목
- 우측: 작업
- // ()
-
-
##
- (Phase) //
-
-
`,
});
export type {
ProcessWorkStandardConfig,
ProcessWorkStandardComponentProps,
WorkPhaseDefinition,
DetailTypeDefinition,
DataSourceConfig,
WorkItem,
WorkItemDetail,
SelectionState,
} from "./types";
export { ProcessWorkStandardComponent } from "./ProcessWorkStandardComponent";
export { ProcessWorkStandardRenderer } from "./ProcessWorkStandardRenderer";
export { ProcessWorkStandardConfigPanel } from "./ProcessWorkStandardConfigPanel";

View File

@ -0,0 +1,111 @@
/**
*
*/
// 작업 단계 정의 (사용자가 추가/삭제/이름변경 가능)
export interface WorkPhaseDefinition {
key: string;
label: string;
sortOrder: number;
}
// 상세 유형 정의 (사용자가 추가/삭제 가능)
export interface DetailTypeDefinition {
value: string;
label: string;
}
// 데이터 소스 설정
export interface DataSourceConfig {
itemTable: string;
itemNameColumn: string;
itemCodeColumn: string;
routingVersionTable: string;
routingFkColumn: string;
routingVersionNameColumn: string;
routingDetailTable: string;
processTable: string;
processNameColumn: string;
processCodeColumn: string;
}
// 전체 Config
export interface ProcessWorkStandardConfig {
dataSource: DataSourceConfig;
phases: WorkPhaseDefinition[];
detailTypes: DetailTypeDefinition[];
splitRatio?: number;
leftPanelTitle?: string;
readonly?: boolean;
}
// ============================================================
// 데이터 모델
// ============================================================
export interface ItemData {
id: string;
item_name: string;
item_code: string;
}
export interface RoutingVersion {
id: string;
version_name: string;
description?: string;
created_date?: string;
processes: RoutingProcess[];
}
export interface RoutingProcess {
routing_detail_id: string;
seq_no: string;
process_code: string;
process_name: string;
is_required?: string;
work_type?: string;
}
export interface WorkItem {
id: string;
routing_detail_id: string;
work_phase: string;
title: string;
is_required: string;
sort_order: number;
description?: string;
detail_count: number;
created_date?: string;
}
export interface WorkItemDetail {
id: string;
work_item_id: string;
detail_type?: string;
content: string;
is_required: string;
sort_order: number;
remark?: string;
created_date?: string;
}
// ============================================================
// 컴포넌트 Props
// ============================================================
export interface ProcessWorkStandardComponentProps {
config: ProcessWorkStandardConfig;
formData?: Record<string, any>;
isPreview?: boolean;
tableName?: string;
}
// 선택 상태
export interface SelectionState {
itemCode: string | null;
itemName: string | null;
routingVersionId: string | null;
routingVersionName: string | null;
routingDetailId: string | null;
processName: string | null;
}

View File

@ -102,6 +102,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"v2-numbering-rule": () => import("@/lib/registry/components/v2-numbering-rule/NumberingRuleConfigPanel"),
"category-manager": () => import("@/lib/registry/components/category-manager/CategoryManagerConfigPanel"),
"universal-form-modal": () => import("@/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel"),
"v2-process-work-standard": () => import("@/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel"),
};
// ConfigPanel 컴포넌트 캐시

View File

@ -109,6 +109,7 @@
"eslint-config-next": "15.4.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"playwright": "^1.58.2",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"prisma": "^6.14.0",
@ -10001,6 +10002,21 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/fstream": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz",
@ -12497,6 +12513,38 @@
"pathe": "^2.0.3"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",

View File

@ -118,6 +118,7 @@
"eslint-config-next": "15.4.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"playwright": "^1.58.2",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"prisma": "^6.14.0",

View File

@ -0,0 +1,135 @@
/**
* formData
* - http://localhost:9771/screens/1599 접속
* - P003 /
*
* 실행: npx tsx scripts/test-formdata-logs.ts
* (Playwright 필요: npx playwright install chromium)
*/
import { chromium } from "playwright";
const TARGET_URL = "http://localhost:9771/screens/1599?menuObjid=1762422235300";
const LOGIN = { userId: "topseal_admin", password: "1234" };
const TARGET_LOGS = ["🔵", "🟡", "🔴", "process_code", "splitPanelParentData"];
async function main() {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
const consoleLogs: string[] = [];
const errors: string[] = [];
page.on("console", (msg) => {
const text = msg.text();
const type = msg.type();
if (type === "error") {
errors.push(`[CONSOLE ERROR] ${text}`);
}
const hasTarget = TARGET_LOGS.some((t) => text.includes(t));
if (hasTarget || type === "error") {
consoleLogs.push(`[${type}] ${text}`);
}
});
console.log("1. 페이지 이동:", TARGET_URL);
await page.goto(TARGET_URL, { waitUntil: "domcontentloaded", timeout: 20000 });
// 로그인 필요 여부 확인
const userIdInput = page.locator('input[name="userId"]').first();
if (await userIdInput.isVisible().catch(() => false)) {
console.log("2. 로그인 페이지 감지 - 로그인 진행");
await page.fill('input[name="userId"]', LOGIN.userId);
await page.fill('input[name="password"]', LOGIN.password);
await page.click('button[type="submit"]').catch(() => page.click('button:has-text("로그인")'));
await page.waitForTimeout(4000);
}
console.log("3. 5초 대기 (페이지 로드)");
await page.waitForTimeout(5000);
// 탭 확인 - 공정 마스터 (첫 번째 탭)
const firstTab = page.getByRole("tab", { name: /공정 마스터/i }).or(page.locator('button:has-text("공정 마스터")')).first();
if (await firstTab.isVisible().catch(() => false)) {
console.log("4. '공정 마스터' 탭 클릭");
await firstTab.click();
await page.waitForTimeout(1500);
}
// 좌측 패널 테이블 데이터 로드 대기
console.log("5. 좌측 패널 데이터 로드 대기");
await page.locator("table tbody tr").first().waitFor({ state: "visible", timeout: 25000 }).catch(() => {
throw new Error("좌측 테이블에 데이터가 없습니다. process_mng에 P003 등 데이터가 있는지 확인하세요.");
});
// P003 행 또는 첫 번째 행 클릭
let rowToClick = page.locator('table tbody tr:has(td:has-text("P003"))').first();
const hasP003 = await rowToClick.isVisible().catch(() => false);
if (!hasP003) {
console.log(" P003 미발견 - 첫 번째 행 클릭");
rowToClick = page.locator("table tbody tr").first();
}
await rowToClick.click();
await page.waitForTimeout(800);
// 우측 패널에서 '추가' 버튼 클릭 (모달 열기)
console.log("6. '추가' 버튼 클릭");
const addBtn = page.locator('button:has-text("추가")').first();
await addBtn.click();
await page.waitForTimeout(2000);
// 모달이 열렸는지 확인
const modal = page.locator('[role="dialog"], [data-state="open"]').first();
await modal.waitFor({ state: "visible", timeout: 5000 }).catch(() => {});
// 모달 내 설비 드롭다운/콤보박스 선택 (v2-select, entity-search-input 등)
console.log("7. 모달 내 설비 선택");
const trigger = page.locator('[role="combobox"], button:has-text("선택"), button:has-text("설비")').first();
if (await trigger.isVisible().catch(() => false)) {
await trigger.click();
await page.waitForTimeout(500);
const option = page.locator('[role="option"], li[role="option"]').first();
if (await option.isVisible().catch(() => false)) {
await option.click();
}
} else {
// select 태그인 경우
const selectEl = page.locator('select').first();
if (await selectEl.isVisible().catch(() => false)) {
await selectEl.selectOption({ index: 1 });
}
}
await page.waitForTimeout(800);
// 저장 전 콘솔 스냅샷
console.log("\n=== 저장 전 콘솔 로그 (formData 관련) ===");
consoleLogs.forEach((l) => console.log(l));
if (errors.length) {
console.log("\n=== 에러 ===");
errors.forEach((e) => console.log(e));
}
// 저장 버튼 클릭 (모달 내부의 저장 버튼)
console.log("\n8. '저장' 버튼 클릭");
const saveBtn = page.locator('[role="dialog"] button:has-text("저장"), [data-state="open"] button:has-text("저장")').first();
await saveBtn.click();
await page.waitForTimeout(3000);
// 저장 후 로그 수집
console.log("\n=== 저장 후 콘솔 로그 (formData 관련) ===");
consoleLogs.forEach((l) => console.log(l));
if (errors.length) {
console.log("\n=== 에러 ===");
errors.forEach((e) => console.log(e));
}
await page.waitForTimeout(2000);
await browser.close();
}
main().catch((e) => {
console.error("테스트 실패:", e);
process.exit(1);
});