Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs
2026-03-30 11:51:45 +09:00
27 changed files with 5213 additions and 698 deletions
+11
View File
@@ -211,3 +211,14 @@ docs/retrospectives/
mes-architecture-guide.md
# MES Reference Documents
docs/mes-reference/
# 테스트 결과물
frontend/test-results/
# Cursor 설정
.cursor/
# Playwright 테스트 (관제탑에서 관리)
frontend/playwright.config.ts
frontend/tests/
frontend/test-results/
+16
View File
@@ -128,6 +128,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검사 결과 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
@@ -255,12 +256,26 @@ app.use("/api/", limiter);
app.use("/api/", refreshTokenIfNeeded);
// 헬스 체크 엔드포인트
let appVersion = "unknown";
try {
// 로컬: ../../package.json, Docker(/app/src/): ../package.json
// eslint-disable-next-line @typescript-eslint/no-var-requires
appVersion = require("../../package.json").version;
} catch {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
appVersion = require("../package.json").version;
} catch {
/* version stays "unknown" */
}
}
app.get("/health", (req, res) => {
res.status(200).json({
status: "OK",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: config.nodeEnv,
version: appVersion,
});
});
@@ -274,6 +289,7 @@ app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/pop", popActionRoutes); // POP 액션 실행
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
app.use("/api/pop/inspection-result", inspectionResultRoutes); // POP 검사 결과 관리
app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes);
@@ -0,0 +1,191 @@
import { Router, Request, Response } from "express";
import { getPool } from "../database/db";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
router.use(authenticateToken);
// ---- 검사 기준 조회 (item_inspection_info) ----
// GET /api/pop/inspection-result/info?itemCode=ITEM-001&inspectionType=입고검사
router.get("/info", async (req: Request, res: Response) => {
const pool = getPool();
const companyCode = (req as any).user?.companyCode;
const { itemCode, itemId, inspectionType } = req.query;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보 없음" });
}
const conditions: string[] = ["company_code = $1", "is_active = 'Y'"];
const params: unknown[] = [companyCode];
let idx = 2;
if (itemCode) {
conditions.push(`item_code = $${idx++}`);
params.push(itemCode);
}
if (itemId) {
conditions.push(`item_id = $${idx++}`);
params.push(itemId);
}
if (inspectionType) {
conditions.push(`inspection_type = $${idx++}`);
params.push(inspectionType);
}
const sql = `
SELECT id, item_id, item_code, item_name,
inspection_type, inspection_item_name, inspection_standard,
inspection_method, pass_criteria, is_required, sort_order, memo
FROM item_inspection_info
WHERE ${conditions.join(" AND ")}
ORDER BY sort_order, inspection_item_name
`;
try {
const result = await pool.query(sql, params);
return res.json({ success: true, data: result.rows });
} catch (err: any) {
return res.status(500).json({ success: false, message: err.message });
}
});
// ---- 검사 결과 조회 ----
// GET /api/pop/inspection-result?referenceId=xxx&referenceTable=yyy&screenId=zzz
router.get("/", async (req: Request, res: Response) => {
const pool = getPool();
const companyCode = (req as any).user?.companyCode;
const { referenceId, referenceTable, screenId } = req.query;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보 없음" });
}
const conditions: string[] = ["company_code = $1"];
const params: unknown[] = [companyCode];
let idx = 2;
if (referenceId) {
conditions.push(`reference_id = $${idx++}`);
params.push(referenceId);
}
if (referenceTable) {
conditions.push(`reference_table = $${idx++}`);
params.push(referenceTable);
}
if (screenId) {
conditions.push(`screen_id = $${idx++}`);
params.push(screenId);
}
const sql = `
SELECT *
FROM inspection_result
WHERE ${conditions.join(" AND ")}
ORDER BY created_date DESC
`;
try {
const result = await pool.query(sql, params);
return res.json({ success: true, data: result.rows });
} catch (err: any) {
return res.status(500).json({ success: false, message: err.message });
}
});
// ---- 검사 결과 저장 (INSERT or UPDATE) ----
// POST /api/pop/inspection-result
router.post("/", async (req: Request, res: Response) => {
const pool = getPool();
const companyCode = (req as any).user?.companyCode;
const writer = (req as any).user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보 없음" });
}
const {
referenceTable,
referenceId,
screenId,
itemId,
itemCode,
itemName,
inspectionType,
items, // 검사 항목별 결과 배열
overallJudgment,
memo,
isCompleted,
} = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "검사 항목이 없습니다" });
}
const client = await pool.connect();
try {
await client.query("BEGIN");
// 기존 결과 삭제 (동일 referenceId + referenceTable 기준 덮어쓰기)
if (referenceId && referenceTable) {
await client.query(
`DELETE FROM inspection_result
WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3`,
[companyCode, referenceId, referenceTable]
);
}
const insertedIds: string[] = [];
for (const item of items) {
const completedFlag = isCompleted ? "Y" : "N";
const completedDate = isCompleted ? new Date() : null;
const insertSql = `
INSERT INTO inspection_result (
company_code, writer,
reference_table, reference_id, screen_id,
inspection_info_id, item_id, item_code, item_name,
inspection_type, inspection_item_name, inspection_standard, pass_criteria, is_required,
measured_value, judgment, overall_judgment, memo,
is_completed, completed_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20
)
RETURNING id
`;
const result = await client.query(insertSql, [
companyCode,
writer,
referenceTable || null,
referenceId || null,
screenId || null,
item.inspectionInfoId || null,
itemId || item.itemId || null,
itemCode || item.itemCode || null,
itemName || item.itemName || null,
inspectionType || item.inspectionType || null,
item.inspectionItemName || null,
item.inspectionStandard || null,
item.passCriteria || null,
item.isRequired || "Y",
item.measuredValue || null,
item.judgment || null,
overallJudgment || null,
memo || null,
completedFlag,
completedDate,
]);
insertedIds.push(result.rows[0].id);
}
await client.query("COMMIT");
return res.json({ success: true, data: { ids: insertedIds } });
} catch (err: any) {
await client.query("ROLLBACK");
return res.status(500).json({ success: false, message: err.message });
} finally {
client.release();
}
});
export default router;
+80 -1
View File
@@ -183,7 +183,8 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
const cardMapping = mappings?.cardList;
const fieldMapping = mappings?.field;
if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) {
if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0 && items.length > 0) {
// ── 카드리스트 기반 INSERT (기존: items 반복) ──
if (!isSafeIdentifier(cardMapping.targetTable)) {
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
}
@@ -300,6 +301,84 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
insertedCount++;
}
}
} else if (
// ── 필드 단독 INSERT (카드리스트 없이 pop-field만으로 저장) ──
items.length === 0 &&
fieldMapping?.targetTable &&
Object.keys(fieldMapping.columnMapping).length > 0 &&
Object.keys(fieldValues).length > 0
) {
if (!isSafeIdentifier(fieldMapping.targetTable)) {
throw new Error(`유효하지 않은 테이블명: ${fieldMapping.targetTable}`);
}
const columns: string[] = ["company_code"];
const values: unknown[] = [companyCode];
// 필드 매핑 값 추가
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
if (!isSafeIdentifier(targetColumn)) continue;
columns.push(`"${targetColumn}"`);
values.push(fieldValues[sourceField] ?? null);
}
// hiddenMappings 처리
for (const hm of (fieldMapping.hiddenMappings ?? [])) {
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
if (columns.includes(`"${hm.targetColumn}"`)) continue;
let value: unknown = null;
if (hm.valueSource === "static") {
value = hm.staticValue ?? null;
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
value = fieldValues[hm.sourceDbColumn] ?? null;
}
columns.push(`"${hm.targetColumn}"`);
values.push(value);
}
// autoGenMappings 채번 처리
for (const ag of (fieldMapping.autoGenMappings ?? [])) {
if (!ag.numberingRuleId || !ag.targetColumn) continue;
if (!isSafeIdentifier(ag.targetColumn)) continue;
if (columns.includes(`"${ag.targetColumn}"`)) continue;
try {
const generatedCode = await numberingRuleService.allocateCode(
ag.numberingRuleId, companyCode, fieldValues,
);
columns.push(`"${ag.targetColumn}"`);
values.push(generatedCode);
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
} catch (err: any) {
logger.error("[pop/execute-action] 필드 단독 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
}
}
// 자동 필드 추가 (created_date, updated_date, writer)
if (!columns.includes('"created_date"')) {
columns.push('"created_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"updated_date"')) {
columns.push('"updated_date"');
values.push(new Date().toISOString());
}
if (!columns.includes('"writer"') && userId) {
columns.push('"writer"');
values.push(userId);
}
if (columns.length > 1) {
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
await client.query(
`INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`,
values,
);
insertedCount++;
logger.info("[pop/execute-action] 필드 단독 INSERT 실행", {
table: fieldMapping.targetTable,
columnCount: columns.length,
});
}
}
break;
}
@@ -5957,7 +5957,21 @@ export class ScreenManagementService {
return null;
}
const layoutData = layout.layout_data;
let layoutData = layout.layout_data;
// 이중 래핑 감지 및 자동 언래핑
// layout_data 컬럼에 { version, layout_data: { components, ... } } 형태로 저장된 경우
// 실제 레이아웃은 내부 layout_data에 있으므로 언래핑한다
if (
layoutData &&
layoutData.layout_data &&
typeof layoutData.layout_data === "object" &&
!layoutData.components &&
layoutData.layout_data.components
) {
console.log(`POP 레이아웃 이중 래핑 감지 (screen_id=${screenId}), 자동 언래핑`);
layoutData = layoutData.layout_data;
}
// v1 → v2 자동 마이그레이션
if (layoutData && layoutData.version === "pop-1.0") {
@@ -5994,10 +6008,22 @@ export class ScreenManagementService {
console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
// 이중 래핑 방지: { version, layout_data: { components, ... } } 형태로 전달된 경우 언래핑
if (
layoutData &&
layoutData.layout_data &&
typeof layoutData.layout_data === "object" &&
!layoutData.components &&
layoutData.layout_data.components
) {
console.log(`저장 시 이중 래핑 감지 (screen_id=${screenId}), 자동 언래핑`);
layoutData = layoutData.layout_data;
}
// v5 그리드 레이아웃만 지원
const componentCount = Object.keys(layoutData.components || {}).length;
console.log(`컴포넌트: ${componentCount}`);
// v5 형식 검증
if (layoutData.version && layoutData.version !== "pop-5.0") {
console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`);
@@ -0,0 +1,763 @@
# MES 구조 및 PC-POP 연동 가이드
> 작성일: 2026-03-20
> 대상: PC 화면 개발자 / POP 연동 담당자
> 목적: PC에서 작업지시를 등록할 때, POP(생산실적관리)에 공정이 자동 연동되는 전체 구조를 설명
---
## 1. 전체 구조 개요
### 1.1 시스템 구성
```
[PC 영역 - 브라우저] [POP 영역 - 태블릿]
작업지시 등록 화면 생산실적관리 화면 (4480)
(screen 4155, 4493) 카드 리스트 + 상세 모달
| |
| (1) work_instruction INSERT | (3) 카드 리스트 조회
| (2) create-work-processes 호출 | (4) 접수/실적/확정
v v
=================================================================
[백엔드 - Express + PostgreSQL]
/api/data/work_instruction (범용 CRUD)
/api/pop/production/* (MES 전용 API 10개)
=================================================================
```
### 1.2 데이터 흐름 요약
```
PC 등록 서버 자동 처리 POP 표시
--------- ---------------- ----------
1. 품목 선택 3. 라우팅 공정 조회 5. 카드 목록
2. 작업지시 INSERT ---> 4. work_order_process 6. 접수
+ create-work-processes N건 일괄 INSERT ---> 7. 실적 입력
API 호출 + process_work_result 8. 완료 확정
체크리스트 복사
```
---
## 2. DB 테이블 상세 구조
### 2.1 테이블 관계도
```
[마스터 데이터 - PC에서 사전 등록]
item_info process_mng defect_standard_mng
(품목 마스터) (공정 마스터) (불량 유형 마스터)
| |
v v
item_routing_version -----> item_routing_detail
(품목별 라우팅 버전) (공정 순서 정의)
|
v
process_work_item -----> process_work_item_detail
(공정별 작업항목) (체크리스트 상세)
[트랜잭션 데이터 - PC 등록 + POP 실행]
work_instruction ─── 1:N ──> work_order_process ─── 1:N ──> process_work_result
(작업지시 마스터) (공정별 작업 단위) (체크리스트 결과)
|
parent_process_id (자기참조)
|
work_order_process (분할 행)
(접수/재작업 분할 카드)
|
work_order_process_log
(변경 이력 - 트리거 자동)
```
### 2.2 work_instruction (작업지시 마스터) - 19컬럼
> PC에서 등록하는 핵심 테이블. POP에서는 읽기 전용으로 참조.
| 컬럼 | 타입 | 기본값 | 역할 | 비고 |
|------|------|--------|------|------|
| `id` | varchar | gen_random_uuid() | PK | UUID 자동 생성 |
| `work_instruction_no` | varchar | - | 작업지시번호 | 사용자 채번 (예: WI-20260320-001) |
| `item_id` | varchar | - | 품목 FK | -> item_info.id |
| `status` | varchar | - | 작업지시 상태 | waiting / in_progress / completed / cancelled |
| `progress_status` | varchar | - | 진행상태 | POP에서 완료 시 'completed'로 자동 갱신 |
| `qty` | varchar | - | 지시수량 | 핵심. POP 접수 상한의 기준 |
| `completed_qty` | varchar | '0' | 완성수량 | 마지막 공정 양품 합계로 자동 갱신 |
| `routing` | varchar | - | 라우팅 참조 | 현재 미사용 (비어있음) |
| `worker` | varchar | - | 작업자 | |
| `work_team` | varchar | - | 작업팀 | |
| `equipment_id` | varchar | - | 설비 FK | |
| `start_date` | varchar | - | 시작일 | |
| `end_date` | varchar | - | 종료일(납기) | |
| `reason` | varchar | - | 사유 | |
| `remark` | text | - | 비고 | |
| `company_code` | varchar | - | 멀티테넌시 | 필수 |
| `created_date` | timestamp | now() | 생성일 | |
| `updated_date` | timestamp | now() | 수정일 | |
| `writer` | varchar | - | 작성자 | |
### 2.3 work_order_process (공정별 작업 단위) - 37컬럼
> create-work-processes API 호출 시 자동 생성. POP에서 접수/실적/완료를 처리하는 핵심 테이블.
| 컬럼 그룹 | 컬럼 | 타입 | 기본값 | 역할 |
|-----------|------|------|--------|------|
| **연결** | `wo_id` | varchar | - | -> work_instruction.id (작업지시 FK) |
| | `seq_no` | varchar | - | 공정 순서 (1, 2, 3...) |
| | `routing_detail_id` | varchar | - | -> item_routing_detail.id (라우팅 스냅샷) |
| | `parent_process_id` | varchar | NULL | 분할 시 마스터 행 참조 (NULL = 마스터) |
| **공정정보** | `process_code` | varchar | - | 공정코드 (예: P002) |
| | `process_name` | varchar | - | 공정명 (예: 가공) |
| | `is_required` | varchar | - | 필수 여부 |
| | `is_fixed_order` | varchar | - | 순서 고정 여부 |
| | `standard_time` | varchar | - | 표준시간 |
| | `equipment_code` | varchar | - | 사용 설비 |
| **수량** | `plan_qty` | varchar | - | 계획수량 |
| | `input_qty` | varchar | - | 접수량 (접수 시 설정) |
| | `good_qty` | varchar | - | 양품수량 (누적) |
| | `defect_qty` | varchar | - | 불량수량 (누적) |
| | `total_production_qty` | varchar | - | 총 생산수량 (누적) |
| | `concession_qty` | varchar | '0' | 특채수량 (양품에 합산 + 별도 추적) |
| **상태** | `status` | varchar | - | waiting / acceptable / in_progress / completed |
| | `result_status` | varchar | 'draft' | draft / confirmed |
| **타이머** | `started_at` | varchar | - | 작업 시작 시각 |
| | `paused_at` | varchar | - | 일시정지 시각 |
| | `total_paused_time` | varchar | 0 | 누적 일시정지 시간(초) |
| | `completed_at` | varchar | - | 완료 시각 |
| | `actual_work_time` | varchar | NULL | 실 작업시간(초) |
| **작업자** | `accepted_by` | varchar | - | 접수자 |
| | `accepted_at` | varchar | - | 접수 시각 |
| | `completed_by` | varchar | NULL | 완료 처리자 |
| **실적** | `defect_detail` | varchar | - | 불량 상세 JSON (코드/수량/처분) |
| | `result_note` | varchar | - | 실적 메모 |
| | `attachments` | varchar | - | 첨부파일 |
| **재작업** | `is_rework` | varchar | 'N' | 재작업 카드 여부 (Y/N) |
| | `rework_source_id` | varchar | NULL | 재작업 원본 행 참조 |
| **표준** | `company_code`, `created_date`, `updated_date`, `writer`, `remark` | - | - | 표준 컬럼 |
### 2.4 process_work_result (체크리스트/검사 결과) - 35컬럼
> 공정별 체크리스트. create-work-processes 시 마스터 템플릿에서 복사.
| 컬럼 그룹 | 컬럼 | 역할 |
|-----------|------|------|
| **연결** | `work_order_process_id` | -> work_order_process.id |
| | `source_work_item_id` | -> process_work_item.id (템플릿 원본) |
| | `source_detail_id` | -> process_work_item_detail.id (템플릿 상세) |
| **작업항목** | `work_phase` | 작업 단계 (PRE/IN/POST) |
| | `item_title` | 작업항목 제목 |
| | `item_sort_order` | 항목 정렬 순서 |
| **검사 상세** | `detail_content` | 검사 내용 |
| | `detail_type` | 상세 유형 |
| | `detail_sort_order` | 상세 정렬 순서 |
| | `is_required` | 필수 여부 |
| **검사 기준** | `inspection_code` | 검사 코드 |
| | `inspection_method` | 검사 방법 |
| | `unit` | 단위 |
| | `lower_limit` / `upper_limit` | 하한/상한 |
| **입력** | `input_type` | 입력 유형 |
| | `lookup_target` | 조회 대상 |
| | `display_fields` | 표시 필드 |
| | `duration_minutes` | 소요시간(분) |
| **결과** | `result_value` | 입력 결과값 |
| | `is_passed` | 합격 여부 |
| | `status` | pending / completed |
| | `recorded_by` | 기록자 |
| | `recorded_at` | 기록 시각 |
| **그룹 타이머** | `group_started_at`, `group_paused_at` | 그룹 시작/정지 |
| | `group_total_paused_time`, `group_completed_at` | 누적 정지/완료 |
### 2.5 마스터 데이터 테이블
#### item_info (품목 마스터)
| 주요 컬럼 | 역할 |
|-----------|------|
| `id` | PK (UUID) |
| `item_number` | 품목코드 (라우팅 연결 키) |
| `item_name` | 품목명 |
| `type` | 품목 유형 |
| `division` | 구분 |
#### process_mng (공정 마스터)
| 주요 컬럼 | 역할 |
|-----------|------|
| `process_code` | 공정코드 (PK 역할) |
| `process_name` | 공정명 (예: 가공, 검사, 포장) |
| `process_type` | 공정 유형 |
| `use_yn` | 사용 여부 |
#### item_routing_version (품목별 라우팅 버전)
| 주요 컬럼 | 역할 |
|-----------|------|
| `id` | PK (UUID) |
| `item_code` | -> item_info.item_number |
| `version_name` | 버전명 (예: 기본 라우팅, v1) |
| `is_default` | 기본 버전 여부 (boolean) |
#### item_routing_detail (공정 순서 정의)
| 주요 컬럼 | 역할 |
|-----------|------|
| `id` | PK (UUID) |
| `routing_version_id` | -> item_routing_version.id |
| `seq_no` | 공정 순서 (1, 2, 3...) |
| `process_code` | -> process_mng.process_code |
| `is_required` | 필수 여부 |
| `is_fixed_order` | 순서 고정 여부 |
| `standard_time` | 표준시간 |
---
## 3. PC에서 작업지시 등록 -> POP 연동 흐름
### 3.1 전체 시퀀스 다이어그램
```
[PC 사용자] [프론트엔드] [백엔드] [DB]
| | | |
| 1. 품목 선택 | | |
|------------------>| | |
| | 2. 라우팅 버전 조회 | |
| |--- GET /api/data/ | |
| | item_routing_ |--- SELECT |
| | version | item_routing_ |
| | ?item_code=XXX ->| version -->|
| |<-------------------|<----------------------|
| | | |
| 3. 정보 입력 | | |
| (수량/납기/etc) | | |
|------------------>| | |
| | | |
| 4. "등록" 클릭 | | |
|------------------>| | |
| | 5. 작업지시 INSERT | |
| |--- POST /api/data/ | |
| | work_instruction |--- INSERT |
| | --->| work_instruction -->|
| |<-------------------|<-- RETURNING id ------|
| | | |
| | 6. 공정 일괄 생성 | |
| |--- POST /api/pop/ | |
| | production/ | |
| | create-work- | |
| | processes -->| |
| | |-- SELECT item_routing |
| | | _detail + process_mng|
| | |<----------------------|
| | | |
| | |-- FOR EACH 공정: |
| | | INSERT work_order_ |
| | | process -->|
| | | INSERT process_work_ |
| | | result (체크리스트)->|
| | | |
| |<-- 성공 응답 ------| |
|<-- 등록 완료 -----| | |
| | | |
| | [이 시점부터 POP에서 조회 가능] |
```
### 3.2 Step 1: 작업지시 INSERT (필수)
**API**: `POST /api/data/work_instruction`
**필수 데이터**:
```json
{
"item_id": "품목 UUID (item_info.id)",
"qty": "지시수량 (예: 500)",
"status": "waiting",
"work_instruction_no": "작업지시번호 (예: WI-20260320-001)"
}
```
**선택 데이터**:
```json
{
"worker": "작업자",
"work_team": "작업팀",
"equipment_id": "설비 UUID",
"start_date": "시작일",
"end_date": "종료일(납기)",
"remark": "비고"
}
```
**응답 예시**:
```json
{
"success": true,
"data": {
"id": "a1b2c3d4-...",
"work_instruction_no": "WI-20260320-001",
"status": "waiting"
}
}
```
> 이 시점에서는 work_instruction 행만 생성되고, POP에서 공정 카드가 표시되지 않는다.
### 3.3 Step 2: 공정 일괄 생성 (핵심 - 반드시 호출해야 POP 연동됨)
**API**: `POST /api/pop/production/create-work-processes`
**인증**: JWT 토큰 필수 (Authorization 헤더)
**필수 파라미터**:
| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `work_instruction_id` | string | Step 1에서 받은 작업지시 ID |
| `routing_version_id` | string | 선택한 라우팅 버전 UUID |
**선택 파라미터**:
| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `item_code` | string | 품목코드 (참고용) |
| `plan_qty` | string | 계획수량 (work_order_process.plan_qty에 저장) |
**요청 예시**:
```json
{
"work_instruction_id": "a1b2c3d4-...작업지시ID",
"routing_version_id": "e5f6g7h8-...라우팅버전ID",
"plan_qty": "500"
}
```
**서버 내부 동작**:
1. **중복 방지**: 해당 work_instruction_id로 이미 공정이 있으면 409 에러
2. **라우팅 조회**: `item_routing_detail` + `process_mng` JOIN으로 공정 목록 취득
3. **공정별 INSERT**: seq_no 순서대로 work_order_process 행 생성
- 1공정: `status = 'acceptable'` (POP에서 즉시 접수 가능)
- 2~N공정: `status = 'waiting'` (앞공정 완료 대기)
4. **체크리스트 복사**: 각 공정의 `routing_detail_id`에 연결된 `process_work_item` + `process_work_item_detail``process_work_result`로 복사
**응답 예시**:
```json
{
"success": true,
"data": {
"processes": [
{ "id": "uuid-1", "seq_no": "1", "process_name": "가공", "checklist_count": 3 },
{ "id": "uuid-2", "seq_no": "2", "process_name": "검사", "checklist_count": 5 },
{ "id": "uuid-3", "seq_no": "3", "process_name": "포장", "checklist_count": 2 }
],
"total_processes": 3,
"total_checklists": 10
}
}
```
**에러 케이스**:
| HTTP | 상황 | 메시지 |
|------|------|--------|
| 400 | 필수 파라미터 누락 | "work_instruction_id와 routing_version_id는 필수입니다." |
| 409 | 이미 공정이 존재 | "이미 공정이 생성된 작업지시입니다." |
| 404 | 라우팅에 공정 없음 | "라우팅 버전에 등록된 공정이 없습니다." |
### 3.4 Step 1 + Step 2를 하나의 트랜잭션으로 묶는 방법
> PC 화면에서 "등록" 버튼 1회 클릭으로 두 API를 순차 호출해야 한다.
```javascript
// PC 프론트엔드 예시 코드
async function registerWorkInstruction(formData) {
// Step 1: 작업지시 INSERT
const wiResponse = await apiClient.post("/api/data/work_instruction", {
item_id: formData.itemId,
qty: formData.qty,
status: "waiting",
work_instruction_no: formData.wiNo,
worker: formData.worker,
start_date: formData.startDate,
end_date: formData.endDate,
});
if (!wiResponse.data.success) {
throw new Error("작업지시 등록 실패: " + wiResponse.data.message);
}
const workInstructionId = wiResponse.data.data.id;
// Step 2: 공정 일괄 생성 (이것이 POP 연동의 핵심)
const processResponse = await apiClient.post(
"/api/pop/production/create-work-processes",
{
work_instruction_id: workInstructionId,
routing_version_id: formData.routingVersionId,
plan_qty: formData.qty,
}
);
if (!processResponse.data.success) {
// 실패 시 작업지시도 삭제 또는 상태 변경 필요
throw new Error("공정 생성 실패: " + processResponse.data.message);
}
return {
workInstruction: wiResponse.data.data,
processes: processResponse.data.data.processes,
};
}
```
### 3.5 PC 화면에서 필요한 사전 데이터 조회
#### 3.5.1 품목 목록 조회
```
GET /api/data/item_info
```
#### 3.5.2 선택한 품목의 라우팅 버전 목록 조회
```
GET /api/data/item_routing_version?item_code={선택한 품목의 item_number}
```
> `is_default = true`인 버전을 자동 선택하면 UX가 좋다.
#### 3.5.3 라우팅 버전의 공정 목록 미리보기 (선택사항)
```
GET /api/data/item_routing_detail?routing_version_id={선택한 버전 ID}
```
> 등록 전에 사용자에게 "이 라우팅에 포함된 공정 목록"을 보여줄 수 있다.
---
## 4. POP 워크플로우 상세
### 4.1 공정 상태 전이 다이어그램
```
[PC 등록]
|
v
waiting ---------> acceptable ---------> in_progress ---------> completed
(대기중) (접수가능) (진행중) (완료)
| | |
| (접수 취소) | | (실적 저장 -> 자동완료)
|<--------------------+ |
| | (수동 확정)
| +----> completed + confirmed
|
| (앞공정에서 양품 발생 시 자동 전환)
|
waiting ---> acceptable
```
**상태별 의미**:
| 상태 | 의미 | POP 탭 | 전환 조건 |
|------|------|--------|----------|
| `waiting` | 앞공정 미완료, 접수 불가 | 대기 탭 | 앞공정 양품 발생 시 -> acceptable |
| `acceptable` | 접수 가능, 작업자 대기 | 접수가능 탭 | 작업자가 접수 시 -> in_progress (분할 행) |
| `in_progress` | 작업 진행 중 | 진행 탭 | 실적 전량 생산 시 -> completed (자동) |
| `completed` | 작업 완료 | 완료 탭 | 수동 확정 또는 자동 완료 |
### 4.2 수량 계산 공식
```
접수가능량 = 앞공정.양품합계 - 내공정.접수합계
= (앞공정 SUM(good_qty + concession_qty)) - (내공정 분할행 SUM(input_qty))
(1공정은 앞공정.양품합계 = work_instruction.qty)
양품 = 생산수량 - 불량수량
= production_qty - defect_qty
(서버에서 계산, 클라이언트 값은 참고만)
특채(concession) = 불량 중 disposition='accept'인 항목 합계
양품에는 포함되지 않으나, 다음 공정 전달량에 합산
자동완료 조건:
- 분할 행: total_production_qty >= input_qty
- 마스터 행: 모든 분할 행 completed + 잔여 접수가능 <= 0
- 작업지시: 마지막 seq_no의 모든 행 completed
```
### 4.3 접수(Accept) 상세
```
[접수가능 카드 클릭] -> [수량 입력 모달] -> [접수 확인]
|
v
POST /api/pop/production/accept-process
body: { work_order_process_id: 마스터행ID,
accept_qty: 접수수량 }
|
v
[분할 행 INSERT]
- parent_process_id = 마스터행ID
- input_qty = 접수수량
- status = 'in_progress'
- accepted_by = 로그인 사용자
- 체크리스트 복사
|
v
[POP 카드 갱신]
- 새 분할 카드가 "진행중" 탭에 표시
- 잔여 접수가능량이 남으면 마스터 카드도 "접수가능"에 유지
```
### 4.4 실적 저장(Save Result) 상세
```
[진행중 카드 클릭] -> [상세 모달 열림] -> [실적 입력]
|
생산수량 + 불량상세(코드/수량/처분) 입력
|
v
POST /api/pop/production/save-result
body: { work_order_process_id: 분할행ID,
production_qty: 생산수량,
defect_detail: [
{ defect_code, defect_name, qty, disposition }
] }
|
v
[서버 처리]
1. 양품/불량/특채 서버 계산
2. 기존 수량에 누적 (total_production_qty += production_qty)
3. 불량 상세 JSON 병합
4. disposition='rework' -> 재작업 카드 자동 생성
5. 양품 발생 -> 다음 공정 마스터 acceptable로 전환
6. 접수분 전량 생산 -> 분할 행 자동 completed
7. 모든 분할 행 완료 + 잔여 0 -> 마스터 자동 completed
8. 마지막 공정 전부 완료 -> work_instruction 완료
```
**불량 처분(disposition) 3종**:
| 처분 | 의미 | 수량 영향 |
|------|------|----------|
| `scrap` (폐기) | 폐기 처리 | 불량 집계에 포함, 양품에서 차감 |
| `rework` (재작업) | 같은 공정에서 재작업 | 재작업 카드 자동 생성, 양품에서 차감 |
| `accept` (특채) | 조건부 합격 | concession_qty에 기록, 다음 공정 전달량에 합산 |
### 4.5 확정(Confirm Result) 상세
```
수동 확정 = 접수분 전량 미생산이지만 강제로 완료 처리
(예: 생산 중단, 일부만 완료 등)
POST /api/pop/production/confirm-result
body: { work_order_process_id: 분할행ID }
결과:
- status = 'completed'
- result_status = 'confirmed'
- 양품 있으면 다음 공정 활성화
- 마스터/작업지시 캐스케이드 완료 판정
```
---
## 5. MES 전용 API 엔드포인트 정리
**베이스 URL**: `/api/pop/production`
**인증**: 모든 엔드포인트에 JWT 토큰 필수
| 순서 | 메서드 | URL | 용도 | 호출 주체 |
|------|--------|-----|------|----------|
| 1 | POST | `/create-work-processes` | 작업지시에 공정 일괄 생성 | PC |
| 2 | POST | `/accept-process` | 공정 접수 (분할 행 생성) | POP |
| 3 | POST | `/cancel-accept` | 접수 취소 | POP |
| 4 | POST | `/save-result` | 실적 저장 (누적) | POP |
| 5 | POST | `/confirm-result` | 실적 확정 (수동 완료) | POP |
| 6 | GET | `/available-qty` | 접수가능량 조회 | POP |
| 7 | GET | `/result-history` | 차수별 실적 이력 | POP |
| 8 | GET | `/defect-types` | 불량 유형 목록 | POP |
| 9 | POST | `/timer` | 공정 타이머 (시작/정지/완료) | POP |
| 10 | POST | `/group-timer` | 그룹 타이머 (체크리스트별) | POP |
---
## 6. PC 화면 현황 (COMPANY_7 탑씰)
### 6.1 등록된 화면 목록
| 화면 ID | 이름 | 메인 테이블 | 용도 |
|---------|------|------------|------|
| 4155 | 작업지시 목록 | work_instruction | 목록 조회 |
| 4493 | 작업지시 등록화면 | work_instruction | 신규 등록 |
| 4156 | 수주 선택 | sales_order_detail | 모달 (수주 참조) |
| 4157 | 적용 확인 | work_instruction | 모달 (등록 확인) |
### 6.2 메뉴 구조
```
생산관리 (COMPANY_7)
├── 생산옵션설정 (/screens/1606)
├── 생산계획 (/screens/3985)
├── 작업지시 (/production/work-instruction) <-- React 페이지 미구현
├── 공정정보관리 (/production/process-info)
├── 생산실적 (하위 없음)
└── 생산리포트 (/admin/report/production)
```
### 6.3 현재 미구현 사항 (PC 개발 필요)
1. **라우팅 버전 선택 UI**: 품목 선택 시 해당 품목의 라우팅 버전 목록을 표시하고 선택하는 기능
2. **create-work-processes 호출 연동**: 작업지시 등록 시 자동으로 공정 생성 API를 호출하는 로직
3. **work_instruction.routing 컬럼 활용**: 현재 비어있음. routing_version_id를 저장하면 추적 가능
4. **작업지시 상태 관리**: 등록/수정/취소 워크플로우
---
## 7. 마스터 데이터 사전 등록 요건
> 작업지시 등록 전에 다음 마스터 데이터가 반드시 등록되어 있어야 한다.
### 7.1 필수 사전 등록 순서
```
1. process_mng (공정 마스터) 등록
예: P002=가공, P003=검사, P009=포장
2. item_info (품목 마스터) 등록
예: item_number=R_FREE3_002, item_name=원제_AK1000
3. item_routing_version (라우팅 버전) 등록
item_code = item_info.item_number
is_default = true (기본 버전)
4. item_routing_detail (공정 순서) 등록
routing_version_id = 위에서 만든 버전 ID
seq_no = 1, process_code = P002 (1공정: 가공)
seq_no = 2, process_code = P003 (2공정: 검사)
seq_no = 3, process_code = P009 (3공정: 포장)
5. (선택) process_work_item + detail (체크리스트 템플릿)
routing_detail_id = 위의 item_routing_detail.id
```
### 7.2 현재 COMPANY_7 데이터 현황
**등록된 공정 (15개)**:
| 공정코드 | 공정명 | 유형 |
|---------|--------|------|
| P002 | 가공 | PT001 |
| P003 | 검사 | PT003 |
| P005 | 치수검사 | PT004 |
| P006 | 테스트 | PT001 |
| P007 | 인쇄 | PT006 |
| P008 | 조립 | PT002 |
| P009 | 포장 | PT004 |
| PRC-001 ~ PRC-006 | 검수/가공/조립/검사/포장 | 다양 |
| PROC-001 | 확인 | 세척 |
**등록된 라우팅 버전 (24건)**: 다양한 품목에 대해 1~3개 공정 조합
---
## 8. 데이터 예시: 전체 흐름 시뮬레이션
### 시나리오: 품목 R_FREE3_002를 500개 생산
#### Step 1: 작업지시 등록 (PC)
```sql
-- 자동 생성되는 행
INSERT INTO work_instruction (work_instruction_no, item_id, qty, status)
VALUES ('WI-20260320-001', 'a4e492a0-...', '500', 'waiting');
-- id = 'wi-new-001' (UUID 자동생성)
```
#### Step 2: 공정 생성 API 호출 (PC -> 서버)
```
POST /api/pop/production/create-work-processes
{ work_instruction_id: 'wi-new-001',
routing_version_id: '5cff0c1e-...' }
```
```sql
-- 서버가 자동 생성하는 행 (item_routing_detail 기반)
INSERT INTO work_order_process (wo_id, seq_no, process_code, process_name, status, plan_qty)
VALUES
('wi-new-001', '2', 'P002', '가공', 'acceptable', '500'), -- 1공정: 접수가능
('wi-new-001', '30', 'P006', '테스트', 'waiting', '500'); -- 2공정: 대기
```
#### Step 3: POP에서 작업자가 1공정 접수 (300개)
```sql
-- accept-process가 생성하는 분할 행
INSERT INTO work_order_process (wo_id, seq_no, process_code, status, input_qty,
parent_process_id, accepted_by)
VALUES ('wi-new-001', '2', 'P002', 'in_progress', '300',
'마스터행ID', '작업자A');
```
#### Step 4: POP에서 실적 저장 (생산 300개, 불량 10개)
```sql
-- save-result가 UPDATE하는 행
UPDATE work_order_process
SET total_production_qty = '300',
good_qty = '290', -- 서버 계산: 300 - 10
defect_qty = '10',
status = 'completed' -- 자동완료: 300 >= 300(input_qty)
WHERE id = '분할행ID';
-- 다음 공정 자동 활성화
UPDATE work_order_process
SET status = 'acceptable' -- waiting -> acceptable
WHERE wo_id = 'wi-new-001' AND seq_no = '30'
AND parent_process_id IS NULL;
```
#### Step 5: 2공정도 접수 -> 실적 -> 완료하면
```sql
-- 작업지시 자동 완료
UPDATE work_instruction
SET status = 'completed',
progress_status = 'completed',
completed_qty = '280' -- 마지막 공정 양품 합계
WHERE id = 'wi-new-001';
```
---
## 9. 주의사항 및 제약
### 9.1 필수 규칙
1. **create-work-processes는 1회만 호출 가능**: 같은 작업지시에 대해 2번 호출하면 409 에러
2. **routing_version_id는 필수**: 라우팅 없이는 공정을 생성할 수 없음
3. **1공정만 즉시 접수가능**: 나머지는 앞공정 양품 발생 후 자동 전환
4. **수량은 모두 VARCHAR**: 정수 변환 시 parseInt 필수
5. **멀티테넌시**: 모든 쿼리에 company_code 필터 필수
6. **분할 행 구조**: 접수 시 마스터 행에서 분할 행을 INSERT하는 방식. 마스터 행에는 직접 실적 등록 불가
### 9.2 현재 미활용 컬럼
| 컬럼 | 테이블 | 상태 |
|------|--------|------|
| `routing` | work_instruction | 비어있음 (routing_version_id 저장 권장) |
| `equipment_id` | work_instruction | 등록 가능하나 POP 연동 미구현 |
| `item_id` | work_instruction | 일부 테스트 데이터에서 비어있음 |
### 9.3 자동 완료 판정 주의
- 재작업 카드가 있으면 해당 카드가 완료될 때까지 마스터 행이 완료되지 않음
- 특채(concession_qty)는 양품에 포함되지 않으나 다음 공정 전달량에는 합산됨
- 초과 생산은 경고만 하고 차단하지 않음 (현장 유연성)
@@ -0,0 +1,603 @@
# POP 화면 배포서버 마이그레이션 가이드
> **작성일**: 2026-03-23
> **목적**: 로컬(탑씰 COMPANY_7) POP 화면 5종을 배포서버 COMPANY_21(테스트회사)로 복사
> **대상 화면**: 4173, 4479, 4480, 4576, 4577
> **주의**: DB 작업 전 반드시 백업 후 진행
---
## 0. 개념 정리 (먼저 읽기)
### PopDeployModal이란?
POP 관리 화면 내 내장된 **화면 배포 도구**입니다.
- **접근**: POP 디자이너 > 카테고리 트리 > 그룹 우클릭 또는 배포 버튼
- **하는 일**:
1. 선택한 화면들을 다른 회사 계정으로 복사 (`screen_definitions` 새 행 생성)
2. POP 레이아웃 JSON 복사 (`screen_layouts_pop`)
3. **layout_json 내 화면 ID 참조 자동 리매핑** (screenId, cartScreenId, sourceScreenId, targetScreenId)
4. 카테고리 그룹 구조 생성 (`screen_groups`, `screen_group_screens`)
5. numberingRuleId 자동 제거 (회사별 고유값이므로)
- **제약**: 같은 서버 안에서만 동작 (로컬 → 배포 서버 간 복사 불가)
- **권장 사용 시점**: 배포 DB COMPANY_7에 화면이 먼저 세팅된 후, 같은 배포 서버 내 테스트 계정으로 복사할 때
### 마이그레이션 전체 흐름
```
[로컬 DB / COMPANY_7] ──── SQL 직접 복사 ────→ [배포 DB / COMPANY_7]
PopDeployModal
(배포서버 내)
[배포 DB / COMPANY_21]
(테스트 환경)
```
---
## 1. 현황 요약
### 1-1. 환경 정보
| 구분 | 로컬 DB | 배포 DB |
|------|---------|---------|
| Host | 39.117.244.52:11132 | 211.115.91.141:11134 |
| Database | plm | plm |
| 소스 회사 | COMPANY_7 (탑씰) | - |
| 1차 타겟 | - | COMPANY_7 (탑씰, SQL 직접 삽입) |
| 2차 타겟 | - | COMPANY_21 (테스트회사, PopDeployModal) |
### 1-2. 복사 대상 화면
| 화면 ID | screen_code | screen_name | 역할 |
|---------|-------------|-------------|------|
| 4479 | COMPANY_7_179 | 홈 | POP 메인 홈 화면 |
| 4576 | COMPANY_7_194 | 입고메뉴 | 입고 카테고리 메뉴 |
| 4173 | COMPANY_7_169 | 구매입고 담기 | 구매입고 항목 선택/담기 |
| 4577 | COMPANY_7_195 | 구매입고 장바구니 | 장바구니 확인/입고 확정 |
| 4480 | COMPANY_7_180 | MES공정 | MES 생산실적 관리 |
### 1-3. POP 카테고리 구조 (screen_groups)
```
탑씰 (id:3134, code:TOPSSEAL)
├── 홈 #4479
├── 입고관리 (id:3216, code:INBOUND_MENU)
│ ├── 입고메뉴 #4576
│ └── 구매입고 (id:3221, code:PURCHASE RECEIPT)
│ ├── 구매입고 담기 #4173
│ └── 구매입고 장바구니 #4577
└── 생산실적 (id:3220, code:PRODUCTION RESULTS)
└── MES공정 #4480
```
### 1-4. 화면 간 상호참조 (layout_json 내부)
| 출발 화면 | 참조 방식 | 대상 화면 | JSON 키 |
|----------|----------|----------|---------|
| 4479 홈 | navigate | 4480 MES공정 | `screenId: "4480"` |
| 4479 홈 | navigate | 4576 입고메뉴 | `screenId: "4576"` |
| 4576 입고메뉴 | navigate | 4173 구매입고 | `screenId: "4173"` |
| 4173 구매입고 | cart-save | 4577 장바구니 | `cartScreenId: "4577"` |
| 4577 장바구니 | source | 4173 구매입고 | `sourceScreenId: 4173` (숫자) |
| 4577 장바구니 | navigate | 4173 구매입고 | `targetScreenId: "4173"` |
> PopDeployModal을 사용하면 이 참조들이 모두 자동 리매핑됩니다.
---
## 2. 배포 DB 현황 점검 결과
### 2-1. 테이블 누락 상태
| 테이블 | 로컬 | 배포 | MES/기능 의존도 |
|--------|------|------|----------------|
| work_order_process | O (37컬럼) | **없음** | MES공정 화면 전체 동작 불가 |
| process_work_result | O (35컬럼) | **없음** | MES 체크리스트 기능 불가 |
| work_order_process_log | O (12컬럼+트리거) | **없음** | 공정 변경 이력 로깅 불가 |
| cart_items | O (16컬럼) | **없음** | 구매입고 장바구니 전체 불가 |
### 2-2. 컬럼 누락 상태
| 테이블 | 누락 컬럼 | 영향 |
|--------|----------|------|
| work_instruction | `reason`, `completed_qty` | MES 완료수량 업데이트 실패 |
### 2-3. COMPANY_21 (테스트회사) 현황
| 항목 | 상태 |
|------|------|
| 회사 존재 | O (active) |
| 기존 POP 레이아웃 | 없음 (깨끗한 상태) |
| 로그인 가능 계정 | **0개 (계정 없음!)** |
| 기존 화면 수 | 23개 (일반 ERP 화면들) |
> **중요**: COMPANY_21에는 현재 등록된 사용자가 없습니다.
> 테스트 전에 사용자 계정을 먼저 생성해야 로그인 및 POP 테스트가 가능합니다.
### 2-4. COMPANY_7 (탑씰) 배포 서버 현황
- 기존 POP 레이아웃: `screen_id 4114` (테스트용 1개만 존재)
- 로그인 계정: `topseal_admin`, `topseal_admin2`, `topseal_user`, `test1`, `test2`
- 5개 화면(4173, 4479, 4480, 4576, 4577) 모두 배포 DB에 없음 → 안전하게 삽입 가능
---
## 3. 누락 테이블/컬럼 추가 방법
### 3-1. Vexplor DDL 시스템으로 가능한 작업
Vexplor에는 **관리자 DDL 실행 시스템**이 내장되어 있습니다.
- **접근**: 관리자 > 시스템관리 > 테이블관리 (`/admin/systemMng/tableMngList`)
- **가능한 작업**:
- 테이블 생성 (`POST /api/ddl/tables`)
- 컬럼 추가 (`POST /api/ddl/tables/:tableName/columns`)
- 생성 시 `table_type_columns` 메타데이터 자동 등록
- **권한**: 슈퍼 어드민 계정 (`company_code = '*'`)만 사용 가능
- **불가능한 것**: 트리거 함수 생성 (이건 psql 직접 실행 필요)
### 3-2. 작업 분류
| 작업 | 방법 | 비고 |
|------|------|------|
| work_order_process 생성 | **psql 직접 실행** | 컬럼 37개 + 인덱스 7개, UI 입력보다 SQL이 효율적 |
| process_work_result 생성 | **psql 직접 실행** | 컬럼 35개 |
| cart_items 생성 | **psql 직접 실행** | 컬럼 16개 + 인덱스 5개 |
| work_order_process_log 생성 | **psql 직접 실행** | 트리거 함수 포함 필수 |
| work_instruction 컬럼 추가 | DDL UI 또는 psql | 컬럼 2개, 어느 방법이든 가능 |
| table_type_columns 메타데이터 | psql COPY 명령 | 로컬에서 추출 후 배포에 삽입 |
> **결론**: DDL UI는 컬럼 추가(`work_instruction`) 정도에 활용하고,
> 테이블 생성은 모두 psql SQL 직접 실행이 현실적입니다.
---
## 4. 실행 절차 (단계별)
### STEP 0: 배포 DB 백업
```sql
-- 배포 DB에서 실행
CREATE TABLE backup_20260323_screen_definitions AS
SELECT * FROM screen_definitions WHERE company_code = 'COMPANY_7';
CREATE TABLE backup_20260323_screen_layouts_pop AS
SELECT * FROM screen_layouts_pop WHERE company_code = 'COMPANY_7';
CREATE TABLE backup_20260323_screen_groups AS
SELECT * FROM screen_groups WHERE company_code = 'COMPANY_7';
CREATE TABLE backup_20260323_screen_group_screens AS
SELECT * FROM screen_group_screens WHERE company_code = 'COMPANY_7';
```
---
### STEP 1: 누락 테이블 생성 (배포 DB에서 psql 실행)
```bash
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm
```
#### 1-1. work_order_process
```sql
CREATE TABLE work_order_process (
id character varying(500) NOT NULL DEFAULT (gen_random_uuid())::text,
created_date timestamp without time zone DEFAULT now(),
updated_date timestamp without time zone DEFAULT now(),
writer character varying(255),
company_code character varying(255),
wo_id character varying(500),
seq_no character varying(255),
process_code character varying(255),
process_name character varying(255),
is_required character varying(255),
is_fixed_order character varying(255),
standard_time character varying(255),
status character varying(255),
accepted_by character varying(255),
accepted_at character varying(255),
started_at character varying(255),
completed_at character varying(255),
plan_qty character varying(255),
input_qty character varying(255),
good_qty character varying(255),
defect_qty character varying(255),
equipment_code character varying(255),
remark character varying(500),
paused_at character varying(500),
total_paused_time character varying(500) DEFAULT '0',
routing_detail_id character varying(500),
actual_work_time character varying(500) DEFAULT NULL::character varying,
completed_by character varying(500) DEFAULT NULL::character varying,
total_production_qty character varying(500),
defect_detail character varying(500),
result_note character varying(500),
result_status character varying(500) DEFAULT 'draft'::character varying,
attachments character varying(500),
parent_process_id character varying(500) DEFAULT NULL::character varying,
concession_qty character varying(500) DEFAULT '0'::character varying,
is_rework character varying(500) DEFAULT 'N'::character varying,
rework_source_id character varying(500) DEFAULT NULL::character varying,
CONSTRAINT work_order_process_pkey PRIMARY KEY (id)
);
CREATE INDEX idx_wop_company ON work_order_process (company_code);
CREATE INDEX idx_wop_wo_id ON work_order_process (wo_id);
CREATE INDEX idx_wop_wo_id_seq_no ON work_order_process (wo_id, seq_no);
CREATE INDEX idx_wop_process ON work_order_process (company_code, process_code);
CREATE INDEX idx_wop_status ON work_order_process (company_code, process_code, status);
CREATE INDEX idx_wop_parent_process_id ON work_order_process (parent_process_id);
```
#### 1-2. process_work_result
```sql
CREATE TABLE process_work_result (
id character varying(500) NOT NULL DEFAULT (gen_random_uuid())::text,
created_date timestamp without time zone DEFAULT now(),
updated_date timestamp without time zone DEFAULT now(),
writer character varying(500) DEFAULT NULL::character varying,
company_code character varying(500),
work_order_process_id character varying(500),
source_work_item_id character varying(500),
source_detail_id character varying(500),
work_phase character varying(500),
item_title character varying(500),
item_sort_order character varying(500),
detail_content character varying(500),
detail_type character varying(500),
detail_sort_order character varying(500),
is_required character varying(500),
inspection_code character varying(500),
inspection_method character varying(500),
unit character varying(500),
lower_limit character varying(500),
upper_limit character varying(500),
input_type character varying(500),
lookup_target character varying(500),
display_fields character varying(500),
duration_minutes character varying(500),
status character varying(500),
result_value character varying(500),
is_passed character varying(500),
remark character varying(500),
recorded_by character varying(500),
recorded_at character varying(500),
started_at character varying(500) DEFAULT NULL::character varying,
group_started_at character varying(500) DEFAULT NULL::character varying,
group_paused_at character varying(500) DEFAULT NULL::character varying,
group_total_paused_time character varying(500) DEFAULT NULL::character varying,
group_completed_at character varying(500) DEFAULT NULL::character varying,
CONSTRAINT process_work_result_pkey PRIMARY KEY (id)
);
```
#### 1-3. work_order_process_log + 트리거
```sql
-- 로그 테이블
CREATE TABLE work_order_process_log (
log_id SERIAL PRIMARY KEY,
operation_type character varying(10) NOT NULL,
original_id character varying(100),
changed_column character varying(100),
old_value text,
new_value text,
changed_by character varying(50),
changed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
ip_address character varying(50),
user_agent text,
full_row_before jsonb,
full_row_after jsonb
);
-- 트리거 함수 (로컬 DB에서 정확한 정의 먼저 추출)
-- 로컬에서: SELECT pg_get_functiondef(oid) FROM pg_proc WHERE proname = 'work_order_process_log_trigger_func';
-- 추출한 함수 정의를 아래에 붙여넣기
-- 트리거 등록 (함수 생성 후 실행)
CREATE TRIGGER work_order_process_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON work_order_process
FOR EACH ROW EXECUTE FUNCTION work_order_process_log_trigger_func();
```
#### 1-4. cart_items
```sql
CREATE TABLE cart_items (
id character varying(255) NOT NULL DEFAULT (gen_random_uuid())::text,
created_date timestamp without time zone DEFAULT now(),
updated_date timestamp without time zone DEFAULT now(),
company_code character varying(20),
cart_type character varying(255),
screen_id character varying(255),
user_id character varying(255),
source_table character varying(255),
row_key text,
row_data text,
quantity character varying(255),
unit character varying(255),
package_unit character varying(255),
package_entries text,
status character varying(255),
memo text,
CONSTRAINT cart_items_pkey PRIMARY KEY (id)
);
CREATE INDEX idx_cart_items_company ON cart_items (company_code);
CREATE INDEX idx_cart_items_screen_user ON cart_items (screen_id, user_id);
CREATE INDEX idx_cart_items_type ON cart_items (cart_type);
CREATE INDEX idx_cart_items_status ON cart_items (status);
```
---
### STEP 2: 기존 테이블 컬럼 추가
#### 방법 A: DDL UI (관리자 화면)
1. 슈퍼 어드민 계정으로 배포서버 접속
2. 관리자 > 시스템관리 > 테이블관리
3. `work_instruction` 테이블 선택
4. 컬럼 추가 버튼 → `reason` (varchar 500) 추가
5. 컬럼 추가 버튼 → `completed_qty` (varchar 500, 기본값: '0') 추가
#### 방법 B: SQL 직접
```sql
ALTER TABLE work_instruction
ADD COLUMN IF NOT EXISTS reason character varying(500),
ADD COLUMN IF NOT EXISTS completed_qty character varying(500) DEFAULT '0'::character varying;
```
---
### STEP 3: table_type_columns 메타데이터 복사 (로컬 → 배포)
```bash
# 로컬 DB에서 추출
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
COPY (
SELECT * FROM table_type_columns
WHERE table_name IN ('work_order_process', 'cart_items', 'process_work_result')
) TO STDOUT WITH CSV HEADER" > /tmp/ttc_export.csv
# 배포 DB에 삽입 (충돌 시 무시)
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
COPY table_type_columns FROM STDIN WITH CSV HEADER
ON CONFLICT DO NOTHING" < /tmp/ttc_export.csv
```
---
### STEP 4: 화면 5종 → 배포 COMPANY_7 복사 (SQL)
> 화면 ID 4173~4577은 배포 DB에 없으므로 동일 ID로 안전하게 삽입 가능
```bash
# 로컬에서 screen_definitions 추출
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
COPY (
SELECT * FROM screen_definitions
WHERE screen_id IN (4173, 4479, 4480, 4576, 4577)
) TO STDOUT WITH CSV HEADER" > /tmp/screen_def.csv
# 배포에 삽입
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
COPY screen_definitions FROM STDIN WITH CSV HEADER
ON CONFLICT DO NOTHING" < /tmp/screen_def.csv
# screen_layouts_pop 추출 (layout_id 제외)
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
COPY (
SELECT screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by
FROM screen_layouts_pop
WHERE screen_id IN (4173, 4479, 4480, 4576, 4577)
) TO STDOUT WITH CSV HEADER" > /tmp/screen_layouts.csv
# 배포에 삽입
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
COPY screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
FROM STDIN WITH CSV HEADER
ON CONFLICT (screen_id, company_code) DO NOTHING" < /tmp/screen_layouts.csv
# screen_groups 추출 (그룹 4개: 3134, 3216, 3220, 3221)
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
COPY (
SELECT * FROM screen_groups
WHERE id IN (3134, 3216, 3220, 3221)
) TO STDOUT WITH CSV HEADER" > /tmp/screen_groups.csv
# 배포에 삽입
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
COPY screen_groups FROM STDIN WITH CSV HEADER
ON CONFLICT DO NOTHING" < /tmp/screen_groups.csv
# screen_group_screens 추출
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
COPY (
SELECT * FROM screen_group_screens
WHERE screen_id IN (4173, 4479, 4480, 4576, 4577)
) TO STDOUT WITH CSV HEADER" > /tmp/screen_group_screens.csv
# 배포에 삽입
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
COPY screen_group_screens FROM STDIN WITH CSV HEADER
ON CONFLICT DO NOTHING" < /tmp/screen_group_screens.csv
# 시퀀스 동기화 (배포 DB에서 실행)
SELECT setval('screen_definitions_screen_id_seq',
GREATEST((SELECT MAX(screen_id) FROM screen_definitions),
(SELECT last_value FROM screen_definitions_screen_id_seq)));
SELECT setval('screen_layouts_pop_layout_id_seq',
GREATEST((SELECT MAX(layout_id) FROM screen_layouts_pop),
(SELECT last_value FROM screen_layouts_pop_layout_id_seq)));
SELECT setval('screen_groups_id_seq',
GREATEST((SELECT MAX(id) FROM screen_groups),
(SELECT last_value FROM screen_groups_id_seq)));
SELECT setval('screen_group_screens_id_seq',
GREATEST((SELECT MAX(id) FROM screen_group_screens),
(SELECT last_value FROM screen_group_screens_id_seq)));
```
---
### STEP 5: COMPANY_21 테스트 계정 사용자 생성
> COMPANY_21에 현재 등록된 사용자가 없습니다. 로그인하려면 계정이 필요합니다.
배포서버 관리자 화면(슈퍼 어드민)에서 COMPANY_21 소속 사용자를 추가합니다:
- 관리자 > 회사관리 > COMPANY_21 > 사용자 추가
- 또는 SQL:
```sql
-- user_info 테이블에 테스트 사용자 추가 (기존 패턴 참고)
-- 실제 password 해시는 기존 계정 방식과 동일하게 처리 필요
INSERT INTO user_info (user_id, user_name, company_code, user_type, status, password)
VALUES ('test21', '테스트계정', 'COMPANY_21', 'COMPANY_ADMIN', 'active', '-- 해시된 비밀번호 --');
```
---
### STEP 6: PopDeployModal로 COMPANY_7 → COMPANY_21 복사
1. 배포서버에서 `topseal_admin` 계정으로 로그인
2. POP 디자이너 > POP 관리 화면 > 카테고리 트리 접속
3. "탑씰" 그룹에서 배포 버튼 클릭
4. 대상 회사: **COMPANY_21 (테스트회사)** 선택
5. 5개 화면 포함 여부 확인 후 배포 실행
6. PopDeployModal이 COMPANY_21 전용 새 화면 ID 자동 부여 + 참조 자동 리매핑
---
### STEP 7: 검증
```sql
-- 배포 DB에서 실행 --
-- 7-1. 누락 테이블 생성 확인
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('work_order_process', 'cart_items', 'process_work_result', 'work_order_process_log');
-- 예상: 4건
-- 7-2. work_instruction 컬럼 확인
SELECT column_name FROM information_schema.columns
WHERE table_name = 'work_instruction' AND column_name IN ('reason', 'completed_qty');
-- 예상: 2건
-- 7-3. COMPANY_7 화면 삽입 확인
SELECT screen_id, screen_name, company_code FROM screen_definitions
WHERE screen_id IN (4173, 4479, 4480, 4576, 4577);
-- 예상: 5건 (COMPANY_7)
-- 7-4. COMPANY_21 화면 복사 확인 (PopDeployModal 후)
SELECT sd.screen_id, sd.screen_name, sd.company_code,
CASE WHEN slp.layout_id IS NOT NULL THEN 'Y' ELSE 'N' END as has_layout
FROM screen_definitions sd
LEFT JOIN screen_layouts_pop slp ON sd.screen_id = slp.screen_id
WHERE sd.company_code = 'COMPANY_21'
AND sd.screen_name IN ('', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니');
-- 예상: 5건 + has_layout = Y
-- 7-5. 화면 간 참조 무결성 (COMPANY_21 기준 새 ID로 리매핑됐는지 확인)
SELECT slp.screen_id,
layout_data::text LIKE '%screenId%' as has_nav_ref,
layout_data::text LIKE '%cartScreenId%' as has_cart_ref
FROM screen_layouts_pop slp
JOIN screen_definitions sd ON slp.screen_id = sd.screen_id
WHERE sd.company_code = 'COMPANY_21'
AND sd.screen_name IN ('', '입고메뉴', '구매입고 담기', '구매입고 장바구니');
-- 7-6. 시퀀스 정합성
SELECT 'screen_definitions' as tbl,
(SELECT MAX(screen_id) FROM screen_definitions) as max_id,
(SELECT last_value FROM screen_definitions_screen_id_seq) as seq_val,
CASE WHEN (SELECT last_value FROM screen_definitions_screen_id_seq)
>= (SELECT MAX(screen_id) FROM screen_definitions)
THEN 'OK' ELSE 'MISMATCH' END as status;
```
---
## 5. COMPANY_21 테스트 환경 수정 가능 여부
| 항목 | 수정 가능? | 방법 |
|------|----------|------|
| 회사명/정보 | O | 관리자 > 회사관리 |
| 사용자 추가/수정 | O | 관리자 > 사용자관리 |
| POP 화면 수정 | O | POP 디자이너에서 직접 편집 |
| 화면 삭제 후 재배포 | O | PopDeployModal 재실행 |
| 기존 ERP 화면 영향 없음 | O | POP 레이아웃 별도 테이블 관리 |
> COMPANY_21은 테스트 전용 계정이므로 자유롭게 수정/삭제 가능합니다.
> 기존 23개 ERP 화면(구매관리, 영업관리 등)은 POP과 무관하므로 건드리지 않아도 됩니다.
---
## 6. 요약: 실행 순서
| 순서 | 작업 | 방법 | 담당 |
|------|------|------|------|
| STEP 0 | 배포 DB 백업 | psql | DB 담당자 |
| STEP 1 | 누락 테이블 4개 생성 | psql SQL | DB 담당자 |
| STEP 2 | work_instruction 컬럼 추가 | DDL UI 또는 psql | DB 담당자 |
| STEP 3 | table_type_columns 메타데이터 복사 | psql COPY | DB 담당자 |
| STEP 4 | 화면 5종 COMPANY_7에 삽입 | psql COPY | DB 담당자 |
| STEP 5 | COMPANY_21 테스트 사용자 생성 | 관리자 UI 또는 SQL | 어드민 계정 |
| STEP 6 | PopDeployModal로 COMPANY_21 복사 | 배포서버 UI | 어드민 계정 |
| STEP 7 | 검증 쿼리 실행 | psql | DB 담당자 |
| STEP 8 | 브라우저 POP 화면 테스트 | 브라우저 | 테스터 |
---
## 7. 롤백 방법
```sql
-- COMPANY_21 POP 화면 삭제 (PopDeployModal로 생성된 것)
DELETE FROM screen_group_screens
WHERE screen_id IN (
SELECT screen_id FROM screen_definitions WHERE company_code = 'COMPANY_21'
AND screen_name IN ('', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니')
);
DELETE FROM screen_layouts_pop
WHERE screen_id IN (
SELECT screen_id FROM screen_definitions WHERE company_code = 'COMPANY_21'
AND screen_name IN ('', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니')
);
DELETE FROM screen_definitions
WHERE company_code = 'COMPANY_21'
AND screen_name IN ('', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니');
-- COMPANY_7 화면 삭제 (SQL로 삽입한 것)
DELETE FROM screen_group_screens WHERE screen_id IN (4173, 4479, 4480, 4576, 4577);
DELETE FROM screen_layouts_pop WHERE screen_id IN (4173, 4479, 4480, 4576, 4577);
DELETE FROM screen_definitions WHERE screen_id IN (4173, 4479, 4480, 4576, 4577);
DELETE FROM screen_groups WHERE id IN (3134, 3216, 3220, 3221)
AND id NOT IN (SELECT id FROM backup_20260323_screen_groups);
-- 생성한 테이블 제거
DROP TABLE IF EXISTS work_order_process_log;
DROP TABLE IF EXISTS process_work_result;
DROP TABLE IF EXISTS work_order_process;
DROP TABLE IF EXISTS cart_items;
-- 추가 컬럼 제거
ALTER TABLE work_instruction DROP COLUMN IF EXISTS reason;
ALTER TABLE work_instruction DROP COLUMN IF EXISTS completed_qty;
```
---
*이 문서는 로컬 DB와 배포 DB를 읽기 전용으로 점검한 결과를 바탕으로 작성되었습니다.*
*실제 실행 전 반드시 배포 DB 백업을 완료하세요.*
+1 -1
View File
@@ -509,7 +509,7 @@ select {
}
/* ===== 전체 폰트 사이즈 16px 통일 (버튼 제외) ===== */
body *:not(button, [role="button"]) {
body *:not(button, [role="button"], .kpi-dynamic-font) {
font-size: 16px !important;
}
@@ -632,9 +632,8 @@ function renderActualComponent(
if (ActualComp) {
return (
<div className="h-full w-full overflow-hidden">
<ActualComp
config={component.config}
label={component.label}
<ActualComp
config={component.config}
componentId={component.id}
screenId={screenId}
currentRowSpan={effectivePosition?.rowSpan}
@@ -645,11 +644,10 @@ function renderActualComponent(
);
}
// 미등록 컴포넌트: 플레이스홀더 (fallback)
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// 미등록 컴포넌트: 플레이스홀더 (fallback) - 뷰 모드에서는 빈 영역 표시
return (
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-xs text-muted-foreground">{component.label || typeLabel}</span>
<span className="text-xs text-muted-foreground"></span>
</div>
);
}
@@ -231,11 +231,15 @@ export default function PopViewerWithModals({
if (!isTopModal || !closeOnEsc) e.preventDefault();
}}
>
<DialogHeader className={isFull ? "shrink-0 border-b px-4 py-2" : "px-4 pt-4 pb-2"}>
<DialogTitle className="text-base">
{definition.title}
</DialogTitle>
</DialogHeader>
{definition.title ? (
<DialogHeader className={isFull ? "shrink-0 border-b px-4 py-2" : "px-4 pt-4 pb-2"}>
<DialogTitle className="text-base">
{definition.title}
</DialogTitle>
</DialogHeader>
) : (
<DialogTitle className="sr-only"></DialogTitle>
)}
<div className={isFull ? "flex-1 overflow-auto" : "px-4 pb-4"}>
<PopRenderer
layout={modalLayout}
@@ -652,11 +652,12 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
const { subscribe, publish } = usePopEvent(screenId || "default");
// 장바구니 모드 상태 (v1 preset 또는 v2 tasks에 cart-save가 있으면 활성)
// showCartBadge: true인 경우에도 활성화 (cart-save 없이 배지만 표시할 때)
const v2Tasks = (config && "tasks" in config && Array.isArray((config as any).tasks))
? (config as any).tasks as PopButtonTask[]
: null;
const hasCartSaveTask = !!v2Tasks?.some((t) => t.type === "cart-save");
const isCartMode = config?.preset === "cart" || hasCartSaveTask;
const isCartMode = config?.preset === "cart" || hasCartSaveTask || !!(config as any)?.showCartBadge;
const isInboundConfirmMode = config?.preset === "inbound-confirm";
const [cartCount, setCartCount] = useState(0);
const [cartIsDirty, setCartIsDirty] = useState(false);
@@ -0,0 +1,481 @@
"use client";
/**
*
*
* "검사" .
* - (item_inspection_info )
* - +
* - +
* -
*/
import React, { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
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 { Badge } from "@/components/ui/badge";
import { Loader2, CheckCircle2, XCircle, AlertCircle, ClipboardList } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
// ===== 타입 =====
interface InspectionInfoItem {
id: string;
item_id: string;
item_code: string;
item_name: string;
inspection_type: string;
inspection_item_name: string;
inspection_standard: string;
pass_criteria: string;
is_required: string;
sort_order: string;
memo: string;
}
interface InspectionResultItem {
inspectionInfoId: string;
inspectionItemName: string;
inspectionStandard: string;
passCriteria: string;
isRequired: string;
measuredValue: string;
judgment: "pass" | "fail" | "";
}
interface InspectionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 카드 행 데이터 */
rowData: Record<string, unknown>;
/** 품목 코드 (item_inspection_info 조회용) */
itemCode?: string;
/** 품목명 */
itemName?: string;
/** 참조 ID (검사 결과 저장 key) */
referenceId?: string;
/** 참조 테이블 */
referenceTable?: string;
/** 화면 ID */
screenId?: string;
/** 검사 유형 필터 */
inspectionType?: string;
/** 저장 완료 콜백 */
onSaved?: (overallJudgment: "pass" | "fail") => void;
}
// ===== 판정 배지 =====
function JudgmentBadge({ judgment }: { judgment: "pass" | "fail" | "" }) {
if (!judgment) return null;
return judgment === "pass" ? (
<Badge className="bg-green-600 text-white hover:bg-green-700"></Badge>
) : (
<Badge className="bg-red-600 text-white hover:bg-red-700"></Badge>
);
}
// ===== 메인 컴포넌트 =====
export function InspectionModal({
open,
onOpenChange,
rowData,
itemCode,
itemName,
referenceId,
referenceTable,
screenId,
inspectionType,
onSaved,
}: InspectionModalProps) {
const [infoItems, setInfoItems] = useState<InspectionInfoItem[]>([]);
const [resultItems, setResultItems] = useState<InspectionResultItem[]>([]);
const [overallJudgment, setOverallJudgment] = useState<"pass" | "fail" | "">("");
const [memo, setMemo] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// 검사 항목 + 기존 결과 로드
const loadData = useCallback(async () => {
if (!open) return;
setLoading(true);
setError(null);
try {
// 검사 기준 조회
const params = new URLSearchParams();
if (itemCode) params.set("itemCode", itemCode);
if (inspectionType) params.set("inspectionType", inspectionType);
const infoRes = await apiClient.get<{ success: boolean; data: InspectionInfoItem[] }>(
`/pop/inspection-result/info?${params.toString()}`
);
const infoData = infoRes.data?.data || [];
setInfoItems(infoData);
// 기존 결과 조회
let existingMap: Record<string, { measuredValue: string; judgment: "pass" | "fail" | "" }> = {};
if (referenceId && referenceTable) {
const resultParams = new URLSearchParams({
referenceId,
referenceTable,
});
if (screenId) resultParams.set("screenId", screenId);
const resultRes = await apiClient.get<{
success: boolean;
data: Array<{
inspection_info_id: string;
measured_value: string;
judgment: string;
overall_judgment: string;
memo: string;
}>;
}>(`/pop/inspection-result?${resultParams.toString()}`);
if (resultRes.data?.data && resultRes.data.data.length > 0) {
resultRes.data.data.forEach((r) => {
existingMap[r.inspection_info_id] = {
measuredValue: r.measured_value || "",
judgment: (r.judgment === "pass" || r.judgment === "fail") ? r.judgment : "",
};
});
const firstOverall = resultRes.data.data[0]?.overall_judgment;
if (firstOverall === "pass" || firstOverall === "fail") {
setOverallJudgment(firstOverall);
}
setMemo(resultRes.data.data[0]?.memo || "");
}
}
// 결과 항목 초기화
const items: InspectionResultItem[] = infoData.map((info) => ({
inspectionInfoId: info.id,
inspectionItemName: info.inspection_item_name,
inspectionStandard: info.inspection_standard,
passCriteria: info.pass_criteria,
isRequired: info.is_required,
measuredValue: existingMap[info.id]?.measuredValue || "",
judgment: existingMap[info.id]?.judgment || "",
}));
setResultItems(items);
} catch (err: any) {
setError(err?.message || "데이터 조회 실패");
} finally {
setLoading(false);
}
}, [open, itemCode, inspectionType, referenceId, referenceTable, screenId]);
useEffect(() => {
if (open) loadData();
}, [open, loadData]);
// 종합 판정 자동 계산 (필수 항목 모두 합격이면 합격)
useEffect(() => {
if (resultItems.length === 0) return;
const judgedItems = resultItems.filter((i) => i.judgment !== "");
if (judgedItems.length === 0) {
setOverallJudgment("");
return;
}
const hasAnyFail = resultItems.some((i) => i.isRequired === "Y" && i.judgment === "fail");
if (hasAnyFail) {
setOverallJudgment("fail");
return;
}
const requiredItems = resultItems.filter((i) => i.isRequired === "Y");
const allRequiredJudged = requiredItems.every((i) => i.judgment !== "");
if (allRequiredJudged && requiredItems.length > 0) {
setOverallJudgment("pass");
}
}, [resultItems]);
const updateItem = (index: number, partial: Partial<InspectionResultItem>) => {
setResultItems((prev) => {
const next = [...prev];
next[index] = { ...next[index], ...partial };
return next;
});
};
const handleSave = async (isCompleted: boolean) => {
if (!overallJudgment && isCompleted) {
setError("종합 판정을 선택해주세요.");
return;
}
setSaving(true);
setError(null);
try {
await apiClient.post("/pop/inspection-result", {
referenceTable: referenceTable || "",
referenceId: referenceId || "",
screenId: screenId || "",
itemCode: itemCode || "",
itemName: itemName || "",
inspectionType: inspectionType || "",
items: resultItems,
overallJudgment,
memo,
isCompleted,
});
if (isCompleted && onSaved) {
onSaved(overallJudgment as "pass" | "fail");
}
onOpenChange(false);
} catch (err: any) {
setError(err?.message || "저장 실패");
} finally {
setSaving(false);
}
};
// 진행률
const judgedCount = resultItems.filter((i) => i.judgment !== "").length;
const total = resultItems.length;
const progress = total > 0 ? Math.round((judgedCount / total) * 100) : 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] w-full max-w-lg flex-col gap-0 p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle className="flex items-center gap-2 text-base font-bold">
<ClipboardList className="h-5 w-5 text-primary" />
</DialogTitle>
{(itemCode || itemName) && (
<p className="mt-1 text-sm text-muted-foreground">
{itemCode && <span className="font-medium">{itemCode}</span>}
{itemCode && itemName && " · "}
{itemName}
</p>
)}
</DialogHeader>
{/* 본문 */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<AlertCircle className="h-4 w-4 text-destructive" />
<p className="text-sm text-destructive">{error}</p>
</div>
) : resultItems.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<ClipboardList className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
{itemCode
? `"${itemCode}"에 등록된 검사 항목이 없습니다.`
: "검사 항목이 없습니다."}
</p>
</div>
) : (
<div className="space-y-4">
{/* 진행률 표시 */}
<div className="flex items-center justify-between rounded-md bg-muted/30 px-3 py-2">
<span className="text-xs text-muted-foreground">
{judgedCount}/{total}
</span>
<div className="flex items-center gap-2">
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full rounded-full transition-all",
progress === 100 ? "bg-green-500" : "bg-primary"
)}
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs font-medium">{progress}%</span>
</div>
</div>
{/* 검사 항목 목록 */}
<div className="space-y-3">
{resultItems.map((item, index) => (
<div
key={item.inspectionInfoId}
className={cn(
"rounded-lg border p-3 transition-colors",
item.judgment === "pass" && "border-green-200 bg-green-50",
item.judgment === "fail" && "border-red-200 bg-red-50",
!item.judgment && "border-border bg-card"
)}
>
{/* 항목 헤더 */}
<div className="mb-2 flex items-start justify-between gap-2">
<div className="flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">{item.inspectionItemName}</span>
{item.isRequired === "Y" && (
<Badge variant="outline" className="h-4 px-1 py-0 text-[9px] text-destructive border-destructive/50">
</Badge>
)}
</div>
{item.inspectionStandard && (
<p className="mt-0.5 text-xs text-muted-foreground">
: {item.inspectionStandard}
</p>
)}
{item.passCriteria && (
<p className="text-xs text-muted-foreground">
: {item.passCriteria}
</p>
)}
</div>
<JudgmentBadge judgment={item.judgment} />
</div>
{/* 측정값 입력 */}
<div className="mb-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
value={item.measuredValue}
onChange={(e) => updateItem(index, { measuredValue: e.target.value })}
placeholder="측정값 입력"
className="mt-1 h-10 text-sm"
/>
</div>
{/* 합불 판정 버튼 */}
<div className="flex gap-2">
<button
type="button"
onClick={() => updateItem(index, { judgment: "pass" })}
className={cn(
"flex h-11 flex-1 items-center justify-center gap-1.5 rounded-md border-2 text-sm font-semibold transition-colors",
item.judgment === "pass"
? "border-green-500 bg-green-500 text-white"
: "border-green-200 bg-white text-green-700 hover:bg-green-50"
)}
>
<CheckCircle2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => updateItem(index, { judgment: "fail" })}
className={cn(
"flex h-11 flex-1 items-center justify-center gap-1.5 rounded-md border-2 text-sm font-semibold transition-colors",
item.judgment === "fail"
? "border-red-500 bg-red-500 text-white"
: "border-red-200 bg-white text-red-700 hover:bg-red-50"
)}
>
<XCircle className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
{/* 종합 판정 */}
<div className="rounded-lg border-2 border-dashed p-3">
<Label className="text-sm font-semibold"> </Label>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => setOverallJudgment("pass")}
className={cn(
"flex h-12 flex-1 items-center justify-center gap-2 rounded-md border-2 text-sm font-bold transition-colors",
overallJudgment === "pass"
? "border-green-500 bg-green-500 text-white"
: "border-green-200 bg-white text-green-700 hover:bg-green-50"
)}
>
<CheckCircle2 className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => setOverallJudgment("fail")}
className={cn(
"flex h-12 flex-1 items-center justify-center gap-2 rounded-md border-2 text-sm font-bold transition-colors",
overallJudgment === "fail"
? "border-red-500 bg-red-500 text-white"
: "border-red-200 bg-white text-red-700 hover:bg-red-50"
)}
>
<XCircle className="h-5 w-5" />
</button>
</div>
</div>
{/* 비고 */}
<div>
<Label className="text-sm font-medium"></Label>
<Textarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="비고 입력 (선택사항)"
className="mt-1 min-h-[72px] text-sm"
/>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-2">
<AlertCircle className="h-4 w-4 shrink-0 text-destructive" />
<p className="text-xs text-destructive">{error}</p>
</div>
)}
</div>
)}
</div>
{/* 하단 버튼 */}
<DialogFooter className="shrink-0 border-t px-6 py-4">
<div className="flex w-full gap-2">
<Button
variant="outline"
className="flex-1 h-12 text-sm"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button
variant="outline"
className="flex-1 h-12 text-sm"
onClick={() => handleSave(false)}
disabled={saving || loading || resultItems.length === 0}
>
{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null}
</Button>
<Button
className={cn(
"flex-1 h-12 text-sm font-semibold",
overallJudgment === "pass" && "bg-green-600 hover:bg-green-700",
overallJudgment === "fail" && "bg-red-600 hover:bg-red-700"
)}
onClick={() => handleSave(true)}
disabled={saving || loading || resultItems.length === 0 || !overallJudgment}
>
{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -14,7 +14,7 @@ import { useRouter } from "next/navigation";
import {
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight,
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
Trash2, Search,
Trash2, Search, ClipboardCheck, ClipboardX, ClipboardList,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
@@ -31,8 +31,9 @@ import type {
PackageEntry,
CollectDataRequest,
CollectedDataResponse,
CardListInspectionConfig,
} from "../types";
import {
import {
DEFAULT_CARD_IMAGE,
CARD_PRESET_SPECS,
} from "../types";
@@ -41,6 +42,7 @@ import { screenApi } from "@/lib/api/screen";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useCartSync } from "@/hooks/pop/useCartSync";
import { NumberInputModal } from "./NumberInputModal";
import { InspectionModal } from "./InspectionModal";
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
@@ -856,6 +858,8 @@ export function PopCardListComponent({
}}
onDeleteItem={handleDeleteItem}
onUpdateQuantity={handleUpdateQuantity}
inspectionConfig={effectiveConfig?.inspectionConfig}
screenId={screenId}
/>
);
})}
@@ -943,6 +947,8 @@ function Card({
onToggleSelect,
onDeleteItem,
onUpdateQuantity,
inspectionConfig,
screenId,
}: {
row: RowData;
template?: CardTemplateConfig;
@@ -961,6 +967,8 @@ function Card({
onToggleSelect?: () => void;
onDeleteItem?: (cartId: string) => void;
onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void;
inspectionConfig?: CardListInspectionConfig;
screenId?: string;
}) {
const header = template?.header;
const image = template?.image;
@@ -971,6 +979,10 @@ function Card({
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
// 검사 연동 상태
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
const [inspectionStatus, setInspectionStatus] = useState<"pending" | "pass" | "fail">("pending");
const codeValue = header?.codeField ? row[header.codeField] : null;
const titleValue = header?.titleField ? row[header.titleField] : null;
@@ -1312,6 +1324,61 @@ function Card({
</div>
)}
{/* 검사 상태 행 */}
{inspectionConfig?.enabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsInspectionModalOpen(true);
}}
className={`flex w-full items-center justify-between border-t px-3 py-1.5 transition-colors hover:bg-muted/50 ${
inspectionStatus === "pass"
? "bg-green-50"
: inspectionStatus === "fail"
? "bg-red-50"
: "bg-muted/20"
}`}
>
<div className="flex items-center gap-1.5">
{inspectionStatus === "pass" ? (
<ClipboardCheck className="h-3 w-3 text-green-600" />
) : inspectionStatus === "fail" ? (
<ClipboardX className="h-3 w-3 text-red-600" />
) : (
<ClipboardList className="h-3 w-3 text-muted-foreground" />
)}
<span
className={`text-[10px] font-medium ${
inspectionStatus === "pass"
? "text-green-700"
: inspectionStatus === "fail"
? "text-red-700"
: "text-muted-foreground"
}`}
>
</span>
{inspectionConfig.inspectionType && (
<span className="text-[9px] text-muted-foreground">
[{inspectionConfig.inspectionType}]
</span>
)}
</div>
<span
className={`text-[10px] font-semibold ${
inspectionStatus === "pass"
? "text-green-600"
: inspectionStatus === "fail"
? "text-red-600"
: "text-amber-600"
}`}
>
{inspectionStatus === "pass" ? "합격" : inspectionStatus === "fail" ? "불합격" : "대기"}
</span>
</button>
)}
{inputField?.enabled && (
<NumberInputModal
open={isModalOpen}
@@ -1325,6 +1392,26 @@ function Card({
onConfirm={handleInputConfirm}
/>
)}
{/* 검사 모달 */}
{inspectionConfig?.enabled && (
<InspectionModal
open={isInspectionModalOpen}
onOpenChange={setIsInspectionModalOpen}
rowData={row}
itemCode={inspectionConfig.itemCodeColumn ? String(row[inspectionConfig.itemCodeColumn] ?? "") : undefined}
itemName={inspectionConfig.itemNameColumn ? String(row[inspectionConfig.itemNameColumn] ?? "") : undefined}
referenceId={
inspectionConfig.referenceIdColumn
? String(row[inspectionConfig.referenceIdColumn] ?? "")
: String(row.id ?? "")
}
referenceTable={inspectionConfig.referenceTable || ""}
screenId={screenId}
inspectionType={inspectionConfig.inspectionType}
onSaved={(judgment) => setInspectionStatus(judgment)}
/>
)}
</div>
);
}
@@ -54,12 +54,15 @@ import type {
CartListModeConfig,
CardListSaveMapping,
CardListSaveMappingEntry,
CardListPresetMode,
CardListInspectionConfig,
} from "../types";
import { screenApi } from "@/lib/api/screen";
import {
CARD_SCROLL_DIRECTION_LABELS,
RESPONSIVE_DISPLAY_LABELS,
DEFAULT_CARD_IMAGE,
CARD_LIST_PRESET_MODE_LABELS,
} from "../types";
import {
fetchTableList,
@@ -330,14 +333,25 @@ function BasicSettingsTab({
return (
<div className="space-y-4">
{/* 장바구니 목록 모드 */}
<CollapsibleSection sectionKey="basic-cart-mode" title="장바구니 목록 모드" sections={sections}>
<CartListModeSection
cartListMode={config.cartListMode}
onUpdate={(cartListMode) => onUpdate({ cartListMode })}
{/* ===== 프리셋 모드 (최상단) ===== */}
<CollapsibleSection sectionKey="basic-preset-mode" title="모드 선택" sections={sections}>
<CartPresetModeSection
config={config}
columns={columns}
onUpdate={onUpdate}
/>
</CollapsibleSection>
{/* 장바구니 목록 모드 — 프리셋이 없거나 "normal"일 때만 표시 (프리셋 cart-confirm이 자동 처리) */}
{(!config.presetMode || config.presetMode === "normal") && (
<CollapsibleSection sectionKey="basic-cart-mode" title="장바구니 목록 모드" sections={sections}>
<CartListModeSection
cartListMode={config.cartListMode}
onUpdate={(cartListMode) => onUpdate({ cartListMode })}
/>
</CollapsibleSection>
)}
{/* 테이블 선택 (장바구니 모드 시 숨김) */}
{!isCartListMode && (
<CollapsibleSection sectionKey="basic-table" title="테이블 선택" sections={sections}>
@@ -480,6 +494,23 @@ function BasicSettingsTab({
</CollapsibleSection>
)}
{/* 점검 연동 (일반 목록 모드, 테이블 선택 시) */}
{!isCartListMode && dataSource.tableName && (
<CollapsibleSection
sectionKey="basic-inspection"
title="점검 연동"
sections={sections}
badge={config.inspectionConfig?.enabled ? "ON" : undefined}
>
<InspectionConfigSection
inspectionConfig={config.inspectionConfig}
columns={columns}
tableName={dataSource.tableName}
onUpdate={(inspectionConfig) => onUpdate({ inspectionConfig })}
/>
</CollapsibleSection>
)}
{/* 레이아웃 설정 */}
<CollapsibleSection sectionKey="basic-layout" title="레이아웃 설정" sections={sections}>
<div className="space-y-3">
@@ -3218,3 +3249,264 @@ function SaveMappingSection({
</div>
);
}
// ===== 장바구니 프리셋 모드 섹션 =====
function CartPresetModeSection({
config,
columns,
onUpdate,
}: {
config: PopCardListConfig;
columns: ColumnInfo[];
onUpdate: (partial: Partial<PopCardListConfig>) => void;
}) {
const presetMode = config.presetMode || "normal";
const applyPreset = (mode: CardListPresetMode) => {
if (mode === "normal") {
onUpdate({
presetMode: "normal",
cartAction: undefined,
requireFilter: false,
});
return;
}
if (mode === "cart-add") {
// 컬럼명 패턴으로 자동 추천
const nameCol = columns.find((c) => /_name$/.test(c.name))?.name;
const codeCol = columns.find((c) => /_code$/.test(c.name))?.name;
const qtyCol = columns.find((c) => /_qty$|_quantity$/i.test(c.name))?.name;
const updatedConfig: Partial<PopCardListConfig> = {
presetMode: "cart-add",
requireFilter: true,
requireFilterMessage: "검색 후 목록이 표시됩니다.",
cartAction: {
saveMode: "cart",
keyColumn: "id",
label: "담기",
cancelLabel: "취소",
},
inputField: {
enabled: true,
unit: "EA",
limitColumn: qtyCol,
},
};
// 카드 헤더 자동 설정
if (nameCol || codeCol) {
updatedConfig.cardTemplate = {
...(config.cardTemplate || { body: { fields: [] }, image: { enabled: false } }),
header: {
codeField: codeCol,
titleField: nameCol,
},
};
}
onUpdate(updatedConfig);
return;
}
if (mode === "cart-confirm") {
onUpdate({
presetMode: "cart-confirm",
cartListMode: {
enabled: true,
statusFilter: "in_cart",
},
requireFilter: false,
});
}
};
return (
<div className="space-y-3">
<p className="text-[9px] text-muted-foreground">
.
</p>
<div className="space-y-1.5">
{(["normal", "cart-add", "cart-confirm"] as CardListPresetMode[]).map((mode) => (
<button
key={mode}
type="button"
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-xs transition-colors ${
presetMode === mode
? "border-primary bg-primary/10 text-primary"
: "border-input bg-background hover:bg-accent"
}`}
onClick={() => applyPreset(mode)}
>
<div
className={`h-3 w-3 rounded-full border-2 ${
presetMode === mode
? "border-primary bg-primary"
: "border-muted-foreground"
}`}
/>
<div className="flex-1 text-left">
<span className="font-medium">{CARD_LIST_PRESET_MODE_LABELS[mode]}</span>
{mode === "cart-add" && (
<p className="mt-0.5 text-[9px] text-muted-foreground">
saveMode=cart, , , requireFilter
</p>
)}
{mode === "cart-confirm" && (
<p className="mt-0.5 text-[9px] text-muted-foreground">
cartListMode ( )
</p>
)}
</div>
</button>
))}
</div>
{presetMode === "cart-add" && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-2">
<p className="text-[9px] font-medium text-amber-700"> </p>
<ul className="mt-1 space-y-0.5 text-[9px] text-amber-600">
<li> saveMode: cart ()</li>
<li> 입력: 활성화</li>
<li> / 버튼: 활성화</li>
<li> requireFilter: 활성화</li>
</ul>
<p className="mt-1.5 text-[9px] text-amber-600">
(*_name, *_code) (*_qty) .
릿 .
</p>
</div>
)}
</div>
);
}
// ===== 점검 연동 설정 섹션 =====
function InspectionConfigSection({
inspectionConfig,
columns,
tableName,
onUpdate,
}: {
inspectionConfig?: CardListInspectionConfig;
columns: ColumnInfo[];
tableName: string;
onUpdate: (config: CardListInspectionConfig) => void;
}) {
const cfg: CardListInspectionConfig = inspectionConfig || { enabled: false };
const update = (partial: Partial<CardListInspectionConfig>) => {
onUpdate({ ...cfg, ...partial });
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={cfg.enabled}
onCheckedChange={(enabled) => update({ enabled })}
/>
</div>
<p className="text-[9px] text-muted-foreground">
, .
</p>
{cfg.enabled && (
<div className="space-y-3">
{/* 품목 코드 컬럼 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={cfg.itemCodeColumn || "__none__"}
onValueChange={(val) => update({ itemCodeColumn: val === "__none__" ? undefined : val })}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>{col.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[9px] text-muted-foreground">
item_inspection_info
</p>
</div>
{/* 품목명 컬럼 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={cfg.itemNameColumn || "__none__"}
onValueChange={(val) => update({ itemNameColumn: val === "__none__" ? undefined : val })}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>{col.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 참조 ID 컬럼 */}
<div>
<Label className="text-[10px]"> ID </Label>
<Select
value={cfg.referenceIdColumn || "__none__"}
onValueChange={(val) => update({ referenceIdColumn: val === "__none__" ? undefined : val })}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="기본: id" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> (id)</SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>{col.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[9px] text-muted-foreground">
ID로
</p>
</div>
{/* 검사 유형 */}
<div>
<Label className="text-[10px]"> ()</Label>
<Input
value={cfg.inspectionType || ""}
onChange={(e) => update({ inspectionType: e.target.value || undefined })}
placeholder="예: 입고검사, 공정검사 (빈값=전체)"
className="mt-1 h-7 text-xs"
/>
</div>
{/* 참조 테이블 */}
<div>
<Label className="text-[10px]"> </Label>
<Input
value={cfg.referenceTable || tableName}
onChange={(e) => update({ referenceTable: e.target.value || tableName })}
placeholder={tableName}
className="mt-1 h-7 text-xs"
/>
<p className="mt-1 text-[9px] text-muted-foreground">
(: {tableName})
</p>
</div>
</div>
)}
</div>
);
}
@@ -4,11 +4,12 @@
* KPI
*
* + +
* CSS Container Query
* ResizeObserver
* itemStyle.valueFontSize
*/
import React from "react";
import type { DashboardItem } from "../../types";
import React, { useRef, useEffect, useState } from "react";
import type { DashboardItem, FontSize } from "../../types";
import { TEXT_ALIGN_CLASSES } from "../../types";
import { abbreviateNumber } from "../utils/formula";
@@ -23,25 +24,15 @@ export interface KpiCardProps {
formulaDisplay?: string | null;
}
// ===== 증감 표시 =====
// ===== valueFontSize → 크기 배율 =====
function TrendIndicator({ value }: { value: number }) {
const isPositive = value > 0;
const isZero = value === 0;
const color = isPositive
? "text-emerald-600"
: isZero
? "text-muted-foreground"
: "text-rose-600";
const arrow = isPositive ? "↑" : isZero ? "→" : "↓";
return (
<span className={`inline-flex items-center gap-0.5 text-xs font-medium ${color}`}>
<span>{arrow}</span>
<span>{Math.abs(value).toFixed(1)}%</span>
</span>
);
}
const FONT_SIZE_SCALE: Record<FontSize, number> = {
xs: 0.65,
sm: 0.80,
base: 1.0,
lg: 1.25,
xl: 1.55,
};
// ===== 색상 구간 판정 =====
@@ -66,45 +57,118 @@ export function KpiCardComponent({
const displayValue = data ?? 0;
const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges);
// 라벨 정렬만 사용자 설정, 나머지는 @container 반응형 자동
// valueFontSize 배율 (기본: base = 1.0)
const valueFontSize: FontSize = itemStyle?.valueFontSize ?? "base";
const scale = FONT_SIZE_SCALE[valueFontSize];
// 라벨 정렬
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
// 단위: kpiConfig.unit 우선, fallback으로 item.unit (레거시)
const unit = kpiConfig?.unit ?? item.unit;
// ===== 컨테이너 크기 감지 =====
const containerRef = useRef<HTMLDivElement>(null);
const [containerSize, setContainerSize] = useState({ w: 200, h: 100 });
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setContainerSize({ w: width, h: height });
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// ===== 비례 폰트 크기 계산 =====
// 숫자: 컨테이너 높이의 50% (라벨·단위가 없으면 60%)
// 라벨: 컨테이너 높이의 13%
// 단위: 숫자 크기의 40%
// 추세: 고정 최소값 (너무 작으면 읽기 어려움)
const hasLabel = visibility.showLabel && !!item.label;
const hasTrend = visibility.showTrend && trendValue != null;
const hasSubLabel = visibility.showSubLabel && !!formulaDisplay;
// 수직 공간을 차지하는 요소 수에 따라 숫자 비율 조정
const occupiedLines = (hasLabel ? 1 : 0) + (hasTrend ? 1 : 0) + (hasSubLabel ? 1 : 0);
const valueRatio = occupiedLines === 0 ? 0.62 : occupiedLines === 1 ? 0.52 : 0.42;
// 너비 기준으로도 제한 (너무 넓은 셀에서 지나치게 커지지 않도록)
const baseFromHeight = containerSize.h * valueRatio;
const baseFromWidth = containerSize.w * 0.35;
const baseSize = Math.min(baseFromHeight, baseFromWidth);
const valueFontPx = Math.max(16, Math.round(baseSize * scale));
const labelFontPx = Math.max(11, Math.round(containerSize.h * 0.13 * scale));
const unitFontPx = Math.max(12, Math.round(valueFontPx * 0.40));
const trendFontPx = Math.max(10, Math.round(containerSize.h * 0.09 * scale));
return (
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
{/* 라벨 - 사용자 정렬 적용 */}
{visibility.showLabel && (
<p className={`w-full text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
<div
ref={containerRef}
className="pop-neu-card flex h-full w-full flex-col items-center justify-center p-4"
>
{/* 라벨 */}
{hasLabel && (
<p
className={`kpi-dynamic-font w-full text-muted-foreground ${labelAlignClass}`}
style={{ fontSize: `${labelFontPx}px`, lineHeight: 1.2 }}
>
{item.label}
</p>
)}
{/* 메인 값 - @container 반응형 */}
{/* 메인 값 */}
{visibility.showValue && (
<div className="flex items-baseline gap-1">
<div className="flex items-baseline gap-[0.15em]">
<span
className="text-xl font-bold @[200px]:text-3xl @[350px]:text-4xl @[500px]:text-5xl"
style={valueColor ? { color: valueColor } : undefined}
className="kpi-dynamic-font font-bold leading-none"
style={{
fontSize: `${valueFontPx}px`,
...(valueColor ? { color: valueColor } : {}),
}}
>
{formulaDisplay ?? abbreviateNumber(displayValue)}
</span>
{/* 단위 */}
{visibility.showUnit && kpiConfig?.unit && (
<span className="text-xs text-muted-foreground @[200px]:text-sm">
{kpiConfig.unit}
{visibility.showUnit && unit && (
<span
className="kpi-dynamic-font text-muted-foreground font-medium"
style={{ fontSize: `${unitFontPx}px` }}
>
{unit}
</span>
)}
</div>
)}
{/* 증감율 */}
{visibility.showTrend && trendValue != null && (
<TrendIndicator value={trendValue} />
{hasTrend && trendValue != null && (
<span
className="inline-flex items-center gap-0.5 font-medium"
style={{
fontSize: `${trendFontPx}px`,
color: trendValue > 0 ? "var(--color-emerald-600)" : trendValue === 0 ? "var(--muted-foreground)" : "var(--color-rose-600)",
}}
>
<span>{trendValue > 0 ? "↑" : trendValue === 0 ? "→" : "↓"}</span>
<span>{Math.abs(trendValue).toFixed(1)}%</span>
</span>
)}
{/* 보조 라벨 (수식 표시 등) */}
{visibility.showSubLabel && formulaDisplay && (
<p className="text-xs text-muted-foreground @[200px]:text-sm">
{hasSubLabel && (
<p
className="text-muted-foreground"
style={{ fontSize: `${Math.max(10, Math.round(labelFontPx * 0.85))}px` }}
>
{item.formula?.values.map((v) => v.label).join(" / ")}
</p>
)}
@@ -148,12 +148,17 @@ export function buildAggregationSQL(config: DataSourceConfig): string {
: "";
// COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수
// SUM/AVG 등 숫자 집계 시 varchar 컬럼 대응: CAST AS NUMERIC 적용
const needsCast = ["SUM", "AVG"].includes(aggType);
if (!aggCol) {
selectClause = aggType === "COUNT"
? "COUNT(*) as value"
: `${aggType}(${tableName}.*) as value`;
} else {
selectClause = `${aggType}(${aggCol}) as value`;
const colExpr = needsCast
? `CAST(NULLIF(${aggCol}, '') AS NUMERIC)`
: aggCol;
selectClause = `${aggType}(${colExpr}) as value`;
}
// GROUP BY가 있으면 해당 컬럼도 SELECT에 포함
@@ -95,6 +95,7 @@ export function PopFieldComponent({
const row = res.data[0] as Record<string, unknown>;
const extracted: Record<string, unknown> = {};
const lookupQueue: { fieldId: string; codeValue: string; table: string; codeCol: string; displayCol: string }[] = [];
for (const mapping of readSource.fieldMappings || []) {
if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) {
const raw = row[mapping.columnName];
@@ -105,11 +106,48 @@ export function PopFieldComponent({
parsed = raw as Record<string, unknown>;
}
extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? "";
} else if (mapping.valueSource === "json_extract_lookup" && mapping.columnName && mapping.jsonKey && mapping.lookupTable && mapping.lookupCodeColumn && mapping.lookupDisplayColumn) {
const raw = row[mapping.columnName];
let parsed: Record<string, unknown> = {};
if (typeof raw === "string") {
try { parsed = JSON.parse(raw); } catch { /* ignore */ }
} else if (typeof raw === "object" && raw !== null) {
parsed = raw as Record<string, unknown>;
}
const codeValue = String(parsed[mapping.jsonKey] ?? "");
if (codeValue) {
lookupQueue.push({ fieldId: mapping.fieldId, codeValue, table: mapping.lookupTable, codeCol: mapping.lookupCodeColumn, displayCol: mapping.lookupDisplayColumn });
} else {
extracted[mapping.fieldId] = "";
}
} else if (mapping.valueSource === "db_column" && mapping.columnName) {
extracted[mapping.fieldId] = row[mapping.columnName] ?? "";
}
}
// json_extract_lookup: 코드 값으로 참조 테이블 조회하여 표시명 획득
if (lookupQueue.length > 0) {
const lookupResults = await Promise.allSettled(
lookupQueue.map(async (lq) => {
const lookupRes = await dataApi.getTableData(lq.table, {
page: 1,
size: 1,
filters: { [lq.codeCol]: lq.codeValue },
});
if (Array.isArray(lookupRes.data) && lookupRes.data.length > 0) {
const lookupRow = lookupRes.data[0] as Record<string, unknown>;
return { fieldId: lq.fieldId, value: lookupRow[lq.displayCol] ?? lq.codeValue };
}
return { fieldId: lq.fieldId, value: lq.codeValue };
}),
);
for (const result of lookupResults) {
if (result.status === "fulfilled") {
extracted[result.value.fieldId] = result.value.value;
}
}
}
const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []);
const valuesUpdate: Record<string, unknown> = {};
for (const [fieldId, val] of Object.entries(extracted)) {
@@ -324,6 +362,7 @@ export function PopFieldComponent({
const row = res.data[0] as Record<string, unknown>;
const extracted: Record<string, unknown> = {};
const previewLookupQueue: { fieldId: string; codeValue: string; table: string; codeCol: string; displayCol: string }[] = [];
for (const mapping of cfg.readSource!.fieldMappings || []) {
if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) {
const rawVal = row[mapping.columnName];
@@ -334,11 +373,48 @@ export function PopFieldComponent({
parsed = rawVal as Record<string, unknown>;
}
extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? "";
} else if (mapping.valueSource === "json_extract_lookup" && mapping.columnName && mapping.jsonKey && mapping.lookupTable && mapping.lookupCodeColumn && mapping.lookupDisplayColumn) {
const rawVal = row[mapping.columnName];
let parsed: Record<string, unknown> = {};
if (typeof rawVal === "string") {
try { parsed = JSON.parse(rawVal); } catch { /* ignore */ }
} else if (typeof rawVal === "object" && rawVal !== null) {
parsed = rawVal as Record<string, unknown>;
}
const codeValue = String(parsed[mapping.jsonKey] ?? "");
if (codeValue) {
previewLookupQueue.push({ fieldId: mapping.fieldId, codeValue, table: mapping.lookupTable, codeCol: mapping.lookupCodeColumn, displayCol: mapping.lookupDisplayColumn });
} else {
extracted[mapping.fieldId] = "";
}
} else if (mapping.valueSource === "db_column" && mapping.columnName) {
extracted[mapping.fieldId] = row[mapping.columnName] ?? "";
}
}
// json_extract_lookup: 코드 값으로 참조 테이블 조회하여 표시명 획득 (미리보기)
if (previewLookupQueue.length > 0) {
const previewLookupResults = await Promise.allSettled(
previewLookupQueue.map(async (lq) => {
const lookupRes = await dataApi.getTableData(lq.table, {
page: 1,
size: 1,
filters: { [lq.codeCol]: lq.codeValue },
});
if (Array.isArray(lookupRes.data) && lookupRes.data.length > 0) {
const lookupRow = lookupRes.data[0] as Record<string, unknown>;
return { fieldId: lq.fieldId, value: lookupRow[lq.displayCol] ?? lq.codeValue };
}
return { fieldId: lq.fieldId, value: lq.codeValue };
}),
);
for (const result of previewLookupResults) {
if (result.status === "fulfilled") {
extracted[result.value.fieldId] = result.value.value;
}
}
}
const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []);
const valuesUpdate: Record<string, unknown> = {};
for (const [fieldId, val] of Object.entries(extracted)) {
@@ -388,13 +464,13 @@ export function PopFieldComponent({
ref={containerRef}
className="flex h-full w-full flex-col gap-2 overflow-auto p-1"
>
{cfg.sections.map((section) => {
{cfg.sections.map((section, sIdx) => {
const fields = section.fields || [];
const fieldCount = fields.length;
if (fieldCount === 0) return null;
const cols = resolveColumns(section.columns, fieldCount);
return (
<div key={section.id} className={sectionClassName(section)}>
<div key={section.id || `section-${sIdx}`} className={sectionClassName(section)}>
{section.label && (
<div className="mb-1 text-xs font-medium text-muted-foreground">
{section.label}
@@ -404,17 +480,17 @@ export function PopFieldComponent({
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}
>
{fields.map((field) => {
{fields.map((field, fIdx) => {
const fKey = field.fieldName || field.id;
return (
<FieldRenderer
key={field.id}
key={field.id || `field-${sIdx}-${fIdx}`}
field={{ ...field, fieldName: fKey }}
value={allValues[fKey]}
showLabel={section.showLabels}
error={errors[fKey]}
onChange={handleFieldChange}
sectionStyle={section.style}
sectionStyle={migrateStyle(section.style)}
allValues={allValues}
fieldIdToName={fieldIdToName}
/>
@@ -430,8 +506,8 @@ export function PopFieldComponent({
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${resolveColumns("auto", visibleAutoGens.length)}, 1fr)` }}
>
{visibleAutoGens.map((ag) => (
<AutoGenFieldDisplay key={ag.id} mapping={ag} />
{visibleAutoGens.map((ag, agIdx) => (
<AutoGenFieldDisplay key={ag.id || `autogen-${agIdx}`} mapping={ag} />
))}
</div>
</div>
@@ -470,7 +546,7 @@ function FieldRenderer({
[onChange, field.fieldName]
);
const resolvedStyle = sectionStyle === "summary" ? "display" : sectionStyle === "form" ? "input" : sectionStyle;
const resolvedStyle = sectionStyle;
const inputClassName = cn(
"h-9 w-full rounded-md border px-3 text-sm",
field.readOnly
@@ -664,7 +740,12 @@ function SelectFieldInput({
if (!source) return;
if (source.type === "static" && source.staticOptions) {
setOptions(source.staticOptions);
setOptions(
source.staticOptions.map((o) => ({
value: String(o.value ?? ""),
label: String(o.label ?? ""),
}))
);
return;
}
@@ -774,8 +855,8 @@ function SelectFieldInput({
{emptyMessage}
</div>
) : (
options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
options.map((opt, idx) => (
<SelectItem key={opt.value || `opt-${idx}`} value={opt.value || `__empty_${idx}`}>
{opt.label}
</SelectItem>
))
@@ -30,10 +30,10 @@ function PopFieldPreviewComponent({
{label || "입력 필드"}
</span>
<div className="flex flex-wrap gap-1">
{cfg.sections.map((section) =>
(section.fields || []).slice(0, 3).map((field) => (
{cfg.sections.map((section, sIdx) =>
(section.fields || []).slice(0, 3).map((field, fIdx) => (
<div
key={field.id}
key={field.id || `preview-${sIdx}-${fIdx}`}
className="flex h-5 items-center rounded border border-dashed border-muted-foreground/30 px-1.5"
>
<span className="text-[8px] text-muted-foreground">
@@ -110,12 +110,13 @@ export interface PopFieldSection {
// ===== 저장 설정: 값 소스 타입 =====
export type FieldValueSource = "direct" | "json_extract" | "db_column";
export type FieldValueSource = "direct" | "json_extract" | "db_column" | "json_extract_lookup";
export const FIELD_VALUE_SOURCE_LABELS: Record<FieldValueSource, string> = {
direct: "직접 입력",
json_extract: "JSON 추출",
db_column: "DB 컬럼",
json_extract_lookup: "JSON 추출 + 조회",
};
// ===== 저장 설정: 필드-컬럼 매핑 =====
@@ -170,6 +171,12 @@ export interface PopFieldReadMapping {
valueSource: FieldValueSource;
columnName: string;
jsonKey?: string;
/** json_extract_lookup 전용: 조회할 테이블명 */
lookupTable?: string;
/** json_extract_lookup 전용: JSON에서 추출한 코드 값과 매칭할 컬럼 */
lookupCodeColumn?: string;
/** json_extract_lookup 전용: 화면에 표시할 컬럼 */
lookupDisplayColumn?: string;
}
export interface PopFieldReadSource {
@@ -327,10 +327,8 @@ export function PopIconComponent({
if (pendingNavigate.mode === "screen") {
const targetUrl = `/pop/screens/${pendingNavigate.target}`;
console.log("[PopIcon] 화면 이동:", { target: pendingNavigate.target, url: targetUrl });
window.location.href = targetUrl;
} else if (pendingNavigate.mode === "url") {
console.log("[PopIcon] URL 이동:", pendingNavigate.target);
window.location.href = pendingNavigate.target;
}
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
@@ -17,7 +17,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Monitor, LayoutGrid, LogOut, UserCircle } from "lucide-react";
import { Monitor, LayoutGrid, LogOut, UserCircle, Maximize2, Minimize2 } from "lucide-react";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { useAuth } from "@/hooks/useAuth";
@@ -31,6 +31,7 @@ export interface PopProfileConfig {
avatarSize?: AvatarSize;
showDashboardLink?: boolean;
showPcMode?: boolean;
showAppMode?: boolean;
showLogout?: boolean;
}
@@ -38,6 +39,7 @@ const DEFAULT_CONFIG: PopProfileConfig = {
avatarSize: "md",
showDashboardLink: true,
showPcMode: true,
showAppMode: true,
showLogout: true,
};
@@ -67,6 +69,33 @@ function PopProfileComponent({ config: rawConfig }: PopProfileComponentProps) {
const router = useRouter();
const { user, isLoggedIn, logout } = useAuth();
const [open, setOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// 풀스크린 상태 변경 감지
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
// 초기 상태 동기화
setIsFullscreen(!!document.fullscreenElement);
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, []);
const handleAppMode = useCallback(async () => {
setOpen(false);
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
} catch {
// 풀스크린 API 미지원 또는 사용자 거부
}
}, []);
const config = useMemo(() => ({
...DEFAULT_CONFIG,
@@ -180,6 +209,20 @@ function PopProfileComponent({ config: rawConfig }: PopProfileComponentProps) {
PC
</button>
)}
{config.showAppMode && (
<button
onClick={handleAppMode}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm transition-colors hover:bg-accent"
style={{ minHeight: 48 }}
>
{isFullscreen ? (
<Minimize2 className="h-4 w-4 text-muted-foreground" />
) : (
<Maximize2 className="h-4 w-4 text-muted-foreground" />
)}
{isFullscreen ? "앱 모드 해제" : "📱 앱 모드"}
</button>
)}
{config.showLogout && (
<>
<div className="mx-2 my-1 border-t" />
@@ -276,6 +319,14 @@ function PopProfileConfigPanel({ config: rawConfig, onUpdate }: PopProfileConfig
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground"> ()</Label>
<Switch
checked={config.showAppMode ?? true}
onCheckedChange={(v) => updateConfig({ showAppMode: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground"></Label>
<Switch
@@ -325,6 +376,7 @@ PopComponentRegistry.registerComponent({
avatarSize: "md",
showDashboardLink: true,
showPcMode: true,
showAppMode: true,
showLogout: true,
},
connectionMeta: {
@@ -799,8 +799,8 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
);
}
// 필터 탭 (초성/알파벳) 적용
if (activeFilterTab && displayField) {
// 필터 탭 (초성/알파벳) 적용 — 검색어 입력 시에는 전체 범위에서 검색
if (activeFilterTab && displayField && !searchText.trim()) {
items = items.filter((row) => {
const val = row[displayField];
if (val == null) return false;
@@ -812,9 +812,9 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
return items;
}, [allRows, searchText, searchColumns, colsToShow, searchMode, activeFilterTab, displayField]);
// 그룹화 (필터 탭 활성화 시)
// 그룹화 (필터 탭 활성화 시, 검색어 없을 때만)
const groupedRows = useMemo(() => {
if (!activeFilterTab || !displayField) return null;
if (!activeFilterTab || !displayField || searchText.trim()) return null;
const groups = new Map<string, Record<string, unknown>[]>();
for (const row of filteredRows) {
@@ -828,7 +828,7 @@ function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: Modal
// 정렬
const sorted = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b, "ko"));
return sorted;
}, [filteredRows, activeFilterTab, displayField]);
}, [filteredRows, activeFilterTab, displayField, searchText]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,20 @@
"use client";
import React, { useState } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, ChevronUp, ChevronDown } from "lucide-react";
import type { PopWorkDetailConfig, WorkDetailInfoBarField, ResultSectionConfig, ResultSectionType } from "../types";
import { Plus, Trash2, ChevronUp, ChevronDown, Zap, Loader2 } from "lucide-react";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
import type {
PopWorkDetailConfig,
WorkDetailInfoBarField,
ResultSectionConfig,
ResultSectionType,
PlcDataConfig,
} from "../types";
interface PopWorkDetailConfigPanelProps {
config?: PopWorkDetailConfig;
@@ -51,6 +58,20 @@ const DEFAULT_NAVIGATION = {
showCompleteButton: true,
};
const DEFAULT_PLC_CONFIG: PlcDataConfig = {
connectionId: "",
tableName: "",
deviceColumn: "",
valueColumn: "",
timestampColumn: "",
deviceFilter: "",
tagFilter: "",
label: "",
unit: "EA",
refreshInterval: 30,
displayMode: "number",
};
export function PopWorkDetailConfigPanel({
config,
onChange,
@@ -96,10 +117,16 @@ export function PopWorkDetailConfigPanel({
};
const addSection = (type: ResultSectionType) => {
updateSections([
...sections,
{ id: type, type, enabled: true, showCondition: { type: "always" } },
]);
const newSection: ResultSectionConfig = {
id: type,
type,
enabled: true,
showCondition: { type: "always" },
};
if (type === "plc-data") {
newSection.plcConfig = { ...DEFAULT_PLC_CONFIG };
}
updateSections([...sections, newSection]);
};
const removeSection = (idx: number) => {
@@ -120,6 +147,19 @@ export function PopWorkDetailConfigPanel({
updateSections(next);
};
const updatePlcConfig = (idx: number, partial: Partial<PlcDataConfig>) => {
const next = [...sections];
next[idx] = {
...next[idx],
plcConfig: { ...(next[idx].plcConfig ?? DEFAULT_PLC_CONFIG), ...partial },
};
updateSections(next);
};
// PLC 섹션의 인덱스 찾기
const plcSectionIdx = sections.findIndex((s) => s.type === "plc-data" && s.enabled);
const plcSection = plcSectionIdx >= 0 ? sections[plcSectionIdx] : null;
return (
<div className="space-y-5">
{/* 기본 설정 */}
@@ -148,7 +188,11 @@ export function PopWorkDetailConfigPanel({
{sections.map((s, i) => (
<div
key={s.id}
className="flex items-center gap-1 rounded-md border px-2 py-1"
className={
s.type === "plc-data"
? "flex items-center gap-1 rounded-md border-2 border-blue-300 bg-blue-50 px-2 py-1"
: "flex items-center gap-1 rounded-md border px-2 py-1"
}
>
<div className="flex flex-col">
<button
@@ -168,7 +212,11 @@ export function PopWorkDetailConfigPanel({
<ChevronDown className="h-3 w-3" />
</button>
</div>
<span className="flex-1 truncate text-xs font-medium">
<span className={
s.type === "plc-data"
? "flex-1 truncate text-xs font-medium text-blue-700"
: "flex-1 truncate text-xs font-medium"
}>
{SECTION_TYPE_META[s.type]?.label ?? s.type}
</span>
<Switch
@@ -191,6 +239,14 @@ export function PopWorkDetailConfigPanel({
{availableTypes.length > 0 && <SectionAdder types={availableTypes} onAdd={addSection} />}
</Section>
{/* PLC 데이터 상세 설정 */}
{plcSection && plcSectionIdx >= 0 && (
<PlcDataSettingsPanel
plcConfig={plcSection.plcConfig ?? DEFAULT_PLC_CONFIG}
onChange={(partial) => updatePlcConfig(plcSectionIdx, partial)}
/>
)}
{/* 정보 바 */}
<Section title="작업지시 정보 바">
<ToggleRow
@@ -265,6 +321,483 @@ export function PopWorkDetailConfigPanel({
);
}
// ========================================
// PLC 데이터 상세 설정 패널
// ========================================
interface PlcDataSettingsPanelProps {
plcConfig: PlcDataConfig;
onChange: (partial: Partial<PlcDataConfig>) => void;
}
interface DbConnectionOption {
id: number;
connection_name: string;
}
function PlcDataSettingsPanel({ plcConfig, onChange }: PlcDataSettingsPanelProps) {
// 외부 DB 연결 목록
const [connections, setConnections] = useState<DbConnectionOption[]>([]);
const [connectionsLoading, setConnectionsLoading] = useState(false);
// 테이블 목록
const [tables, setTables] = useState<string[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
// 컬럼 목록
const [columns, setColumns] = useState<string[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
// 디바이스/태그 유니크 값
const [devices, setDevices] = useState<string[]>([]);
const [devicesLoading, setDevicesLoading] = useState(false);
const [tags, setTags] = useState<string[]>([]);
const [tagsLoading, setTagsLoading] = useState(false);
// 1. 외부 DB 연결 목록 로드
useEffect(() => {
let cancelled = false;
setConnectionsLoading(true);
ExternalDbConnectionAPI.getConnections({ is_active: "Y" })
.then((list) => {
if (!cancelled) {
setConnections(
list.map((c) => ({ id: c.id!, connection_name: c.connection_name }))
);
}
})
.catch(() => {
if (!cancelled) setConnections([]);
})
.finally(() => {
if (!cancelled) setConnectionsLoading(false);
});
return () => { cancelled = true; };
}, []);
// 2. 연결 선택 시 테이블 목록 로드
const loadTables = useCallback(async (connId: string) => {
if (!connId) {
setTables([]);
return;
}
setTablesLoading(true);
try {
const res = await ExternalDbConnectionAPI.getTables(Number(connId));
setTables(res.data ?? []);
} catch {
setTables([]);
} finally {
setTablesLoading(false);
}
}, []);
useEffect(() => {
if (plcConfig.connectionId) {
loadTables(plcConfig.connectionId);
}
}, [plcConfig.connectionId, loadTables]);
// 3. 테이블 선택 시 컬럼 목록 로드
const loadColumns = useCallback(async (connId: string, tableName: string) => {
if (!connId || !tableName) {
setColumns([]);
return;
}
setColumnsLoading(true);
try {
const res = await ExternalDbConnectionAPI.getTableColumns(Number(connId), tableName);
const cols = (res.data ?? []).map((c: { column_name?: string; COLUMN_NAME?: string }) =>
c.column_name ?? c.COLUMN_NAME ?? ""
).filter(Boolean);
setColumns(cols);
} catch {
setColumns([]);
} finally {
setColumnsLoading(false);
}
}, []);
useEffect(() => {
if (plcConfig.connectionId && plcConfig.tableName) {
loadColumns(plcConfig.connectionId, plcConfig.tableName);
}
}, [plcConfig.connectionId, plcConfig.tableName, loadColumns]);
// 4. 디바이스/태그 유니크값 로드
const loadUniqueValues = useCallback(async (
connId: string,
tableName: string,
column: string,
setter: (v: string[]) => void,
setLoading: (v: boolean) => void,
) => {
if (!connId || !tableName || !column) {
setter([]);
return;
}
setLoading(true);
try {
const query = `SELECT DISTINCT "${column}" FROM "${tableName}" WHERE "${column}" IS NOT NULL ORDER BY "${column}" LIMIT 100`;
const res = await ExternalDbConnectionAPI.executeQuery(Number(connId), query);
const values = (res.data ?? []).map((row: Record<string, unknown>) => String(row[column] ?? "")).filter(Boolean);
setter(values);
} catch {
setter([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (plcConfig.connectionId && plcConfig.tableName && plcConfig.deviceColumn) {
loadUniqueValues(plcConfig.connectionId, plcConfig.tableName, plcConfig.deviceColumn, setDevices, setDevicesLoading);
} else {
setDevices([]);
}
}, [plcConfig.connectionId, plcConfig.tableName, plcConfig.deviceColumn, loadUniqueValues]);
// 태그 필터 값 로드 - deviceColumn과 다른 텍스트 컬럼이면 유니크 조회
// 실제로는 device_id 선택 후 해당 디바이스의 tag_name 유니크값을 로드
useEffect(() => {
if (!plcConfig.connectionId || !plcConfig.tableName || !plcConfig.deviceColumn) {
setTags([]);
return;
}
// tag는 deviceColumn이 아닌 나머지 text-like 컬럼에서 가져오거나,
// 사용자가 직접 입력 가능. 여기서는 device filter가 선택된 경우 해당 device의 남은 텍스트 컬럼에서 unique를 가져옴
// 간단히: deviceColumn 외의 첫 텍스트 컬럼 또는 사용자가 지정하도록 함
// 지금은 deviceFilter가 선택되었을 때 deviceColumn 조건으로 다른 유니크값 조회
const tagColumn = columns.find(
(c) => c !== plcConfig.deviceColumn && c !== plcConfig.valueColumn && c !== plcConfig.timestampColumn
);
if (tagColumn && plcConfig.deviceFilter) {
const query = `SELECT DISTINCT "${tagColumn}" FROM "${plcConfig.tableName}" WHERE "${plcConfig.deviceColumn}" = '${plcConfig.deviceFilter}' AND "${tagColumn}" IS NOT NULL ORDER BY "${tagColumn}" LIMIT 100`;
setTagsLoading(true);
ExternalDbConnectionAPI.executeQuery(Number(plcConfig.connectionId), query)
.then((res) => {
setTags((res.data ?? []).map((row: Record<string, unknown>) => String(row[tagColumn] ?? "")).filter(Boolean));
})
.catch(() => setTags([]))
.finally(() => setTagsLoading(false));
} else {
setTags([]);
}
}, [plcConfig.connectionId, plcConfig.tableName, plcConfig.deviceColumn, plcConfig.deviceFilter, plcConfig.valueColumn, plcConfig.timestampColumn, columns]);
return (
<div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
<div className="flex items-center gap-2">
<div className="flex h-5 w-5 items-center justify-center rounded bg-blue-500">
<Zap className="h-3 w-3 text-white" />
</div>
<span className="text-xs font-semibold text-blue-800">PLC </span>
</div>
{/* PLC 연동 - DB 연결 */}
<div className="space-y-2">
<div className="text-[11px] font-medium text-blue-700">PLC </div>
{/* DB 연결 */}
<div className="space-y-1">
<span className="text-xs text-gray-600">DB </span>
<Select
value={plcConfig.connectionId}
onValueChange={(v) => onChange({ connectionId: v, tableName: "", deviceColumn: "", valueColumn: "", timestampColumn: "", deviceFilter: "", tagFilter: "" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={connectionsLoading ? "불러오는 중..." : "외부 DB 선택"} />
</SelectTrigger>
<SelectContent>
{connections.map((c) => (
<SelectItem key={c.id} value={String(c.id)} className="text-xs">
{c.connection_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 테이블 */}
<div className="space-y-1">
<span className="text-xs text-gray-600"></span>
<Select
value={plcConfig.tableName}
onValueChange={(v) => onChange({ tableName: v, deviceColumn: "", valueColumn: "", timestampColumn: "", deviceFilter: "", tagFilter: "" })}
disabled={!plcConfig.connectionId}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={tablesLoading ? "불러오는 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((t) => (
<SelectItem key={t} value={t} className="text-xs">{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 디바이스 컬럼 */}
<div className="space-y-1">
<span className="text-xs text-gray-600"> </span>
<Select
value={plcConfig.deviceColumn}
onValueChange={(v) => onChange({ deviceColumn: v, deviceFilter: "", tagFilter: "" })}
disabled={!plcConfig.tableName}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={columnsLoading ? "불러오는 중..." : "컬럼 선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((c) => (
<SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 값 컬럼 */}
<div className="space-y-1">
<span className="text-xs text-gray-600"> </span>
<Select
value={plcConfig.valueColumn}
onValueChange={(v) => onChange({ valueColumn: v })}
disabled={!plcConfig.tableName}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((c) => (
<SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 타임스탬프 컬럼 */}
<div className="space-y-1">
<span className="text-xs text-gray-600"> </span>
<Select
value={plcConfig.timestampColumn}
onValueChange={(v) => onChange({ timestampColumn: v })}
disabled={!plcConfig.tableName}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((c) => (
<SelectItem key={c} value={c} className="text-xs">{c}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 디바이스 필터 */}
<div className="space-y-2">
<div className="text-[11px] font-medium text-blue-700"> </div>
<div className="space-y-1">
<span className="text-xs text-gray-600"></span>
{devicesLoading ? (
<div className="flex items-center gap-1 text-xs text-gray-400">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
) : devices.length > 0 ? (
<Select
value={plcConfig.deviceFilter}
onValueChange={(v) => onChange({ deviceFilter: v, tagFilter: "" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="디바이스 선택" />
</SelectTrigger>
<SelectContent>
{devices.map((d) => (
<SelectItem key={d} value={d} className="text-xs">{d}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-xs"
placeholder="디바이스 ID 직접 입력"
value={plcConfig.deviceFilter}
onChange={(e) => onChange({ deviceFilter: e.target.value })}
/>
)}
</div>
<div className="space-y-1">
<span className="text-xs text-gray-600"></span>
{tagsLoading ? (
<div className="flex items-center gap-1 text-xs text-gray-400">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
) : tags.length > 0 ? (
<Select
value={plcConfig.tagFilter}
onValueChange={(v) => onChange({ tagFilter: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="태그 선택" />
</SelectTrigger>
<SelectContent>
{tags.map((t) => (
<SelectItem key={t} value={t} className="text-xs">{t}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-xs"
placeholder="태그명 직접 입력"
value={plcConfig.tagFilter}
onChange={(e) => onChange({ tagFilter: e.target.value })}
/>
)}
</div>
</div>
{/* 표시 설정 */}
<div className="space-y-2">
<div className="text-[11px] font-medium text-blue-700"> </div>
<div className="space-y-1">
<span className="text-xs text-gray-600"></span>
<Input
className="h-8 text-xs"
placeholder="예: 총 생산수량"
value={plcConfig.label}
onChange={(e) => onChange({ label: e.target.value })}
/>
</div>
<div className="space-y-1">
<span className="text-xs text-gray-600"></span>
<Input
className="h-8 text-xs"
placeholder="예: EA"
value={plcConfig.unit}
onChange={(e) => onChange({ unit: e.target.value })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600"> </span>
<Select
value={String(plcConfig.refreshInterval)}
onValueChange={(v) => onChange({ refreshInterval: Number(v) })}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10" className="text-xs">10</SelectItem>
<SelectItem value="30" className="text-xs">30</SelectItem>
<SelectItem value="60" className="text-xs">60</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600"> </span>
<Select
value={plcConfig.displayMode}
onValueChange={(v) => onChange({ displayMode: v as "number" | "gauge" })}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number" className="text-xs"></SelectItem>
<SelectItem value="gauge" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="border-t border-blue-200" />
{/* 매핑 저장 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-700"> </span>
<Switch
checked={plcConfig.mapping?.enabled ?? false}
onCheckedChange={(v) =>
onChange({
mapping: {
enabled: v,
targetTable: plcConfig.mapping?.targetTable ?? "",
targetColumn: plcConfig.mapping?.targetColumn ?? "",
mode: plcConfig.mapping?.mode ?? "latest",
},
})
}
/>
</div>
{plcConfig.mapping?.enabled && (
<>
<div className="space-y-1">
<span className="text-xs text-gray-600"> </span>
<div className="flex gap-1">
<Input
className="h-7 flex-1 text-xs"
placeholder="테이블명"
value={plcConfig.mapping.targetTable}
onChange={(e) =>
onChange({
mapping: { ...plcConfig.mapping!, targetTable: e.target.value },
})
}
/>
<Input
className="h-7 flex-1 text-xs"
placeholder="컬럼명"
value={plcConfig.mapping.targetColumn}
onChange={(e) =>
onChange({
mapping: { ...plcConfig.mapping!, targetColumn: e.target.value },
})
}
/>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600"> </span>
<Select
value={plcConfig.mapping.mode}
onValueChange={(v) =>
onChange({
mapping: { ...plcConfig.mapping!, mode: v as "latest" | "accumulated" | "delta" },
})
}
>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest" className="text-xs">latest</SelectItem>
<SelectItem value="accumulated" className="text-xs">accumulated</SelectItem>
<SelectItem value="delta" className="text-xs">delta</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
</div>
);
}
// ========================================
// 공통 하위 컴포넌트
// ========================================
function SectionAdder({
types,
onAdd,
+59 -1
View File
@@ -296,6 +296,8 @@ export interface DashboardPage {
export interface ItemStyleConfig {
/** 라벨 텍스트 정렬 (기본: center) */
labelAlign?: TextAlign;
/** KPI 숫자 크기 (기본: @container 반응형 자동) */
valueFontSize?: FontSize;
}
// ----- 대시보드 아이템 -----
@@ -319,8 +321,14 @@ export interface DashboardItem {
gaugeConfig?: GaugeConfig;
statConfig?: StatCardConfig;
/** 스타일 설정 (정렬, 글자 크기 3그룹) */
/** 스타일 설정 (정렬, 글자 크기) */
itemStyle?: ItemStyleConfig;
/** 단위 (레거시 최상위 필드 — kpiConfig.unit 우선) */
unit?: string;
/** 보조 라벨 */
subLabel?: string;
}
// ----- 대시보드 전체 설정 -----
@@ -701,6 +709,28 @@ export interface CardListSaveMapping {
mappings: CardListSaveMappingEntry[];
}
// ----- 장바구니 프리셋 모드 -----
export type CardListPresetMode = "normal" | "cart-add" | "cart-confirm";
export const CARD_LIST_PRESET_MODE_LABELS: Record<CardListPresetMode, string> = {
normal: "일반 목록",
"cart-add": "장바구니 담기",
"cart-confirm": "장바구니 확정 (담긴 목록)",
};
// ----- 검사 연동 설정 -----
export interface CardListInspectionConfig {
enabled: boolean;
itemCodeColumn?: string; // 카드 데이터에서 품목 코드를 읽을 컬럼
itemIdColumn?: string; // 카드 데이터에서 품목 ID를 읽을 컬럼
itemNameColumn?: string; // 품목명 컬럼
inspectionType?: string; // 검사 유형 필터 (빈값=전체)
referenceTable?: string; // 참조 테이블명 (카드 데이터 테이블)
referenceIdColumn?: string; // 참조 ID 컬럼 (기본: id)
}
// ----- pop-card-list 전체 설정 -----
export interface PopCardListConfig {
@@ -724,6 +754,12 @@ export interface PopCardListConfig {
requireFilter?: boolean;
requireFilterMessage?: string;
/** 장바구니 프리셋 모드 (normal | cart-add | cart-confirm) */
presetMode?: CardListPresetMode;
/** 검사 연동 설정 */
inspectionConfig?: CardListInspectionConfig;
}
// =============================================
@@ -1048,11 +1084,33 @@ export type ResultSectionType =
| "barcode-scan"
| "plc-data";
/** PLC 데이터 연동 설정 */
export interface PlcDataConfig {
connectionId: string; // external_db_connections ID
tableName: string; // 예: "edge_telemetry"
deviceColumn: string; // 예: "device_id"
valueColumn: string; // 예: "value"
timestampColumn: string; // 예: "collected_at"
deviceFilter: string; // 예: "PROD-COUNTER-01"
tagFilter: string; // 예: "생산수량_PV"
label: string; // 예: "총 생산수량"
unit: string; // 예: "EA"
refreshInterval: number; // 초 단위 (10, 30, 60)
displayMode: "number" | "gauge";
mapping?: {
enabled: boolean;
targetTable: string;
targetColumn: string;
mode: "latest" | "accumulated" | "delta";
};
}
export interface ResultSectionConfig {
id: string;
type: ResultSectionType;
enabled: boolean;
showCondition?: { type: "always" | "last-process" };
plcConfig?: PlcDataConfig;
}
export interface PopWorkDetailConfig {
+20 -2
View File
@@ -102,6 +102,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.86.0",
"@types/jsbarcode": "^3.11.4",
@@ -1410,6 +1411,23 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -12943,7 +12961,7 @@
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
@@ -12962,7 +12980,7 @@
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
+1
View File
@@ -111,6 +111,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.86.0",
"@types/jsbarcode": "^3.11.4",