From e8c0828d9155124427e9355ab8263f29fd04a3ea Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 24 Feb 2026 10:15:25 +0900 Subject: [PATCH 1/2] feat: Add process work standard component implementation plan - Introduced a comprehensive implementation plan for the v2-process-work-standard component, detailing the current state analysis, required database tables, API design, and implementation phases. - Included a structured file organization plan for both frontend and backend components, ensuring clarity in development and integration. - Updated the V2Repeater component to support new auto-fill functionalities, including parent sequence generation, enhancing data management capabilities. - Enhanced the V2RepeaterConfigPanel to allow configuration of parent sequence settings, improving user experience in managing data entries. --- docs/plans/process-work-standard-plan.md | 427 ++++++++++++++++++ .../00_analysis/v2-component-usage-guide.md | 111 +++-- frontend/components/v2/V2Repeater.tsx | 88 +++- .../config-panels/V2RepeaterConfigPanel.tsx | 51 +++ .../lib/registry/DynamicComponentRenderer.tsx | 3 +- frontend/types/v2-repeater.ts | 7 +- 6 files changed, 651 insertions(+), 36 deletions(-) create mode 100644 docs/plans/process-work-standard-plan.md diff --git a/docs/plans/process-work-standard-plan.md b/docs/plans/process-work-standard-plan.md new file mode 100644 index 00000000..d455cfae --- /dev/null +++ b/docs/plans/process-work-standard-plan.md @@ -0,0 +1,427 @@ +# 공정 작업기준 컴포넌트 (v2-process-work-standard) 구현 계획 + +> **작성일**: 2026-02-24 +> **컴포넌트 ID**: `v2-process-work-standard` +> **성격**: 도메인 특화 컴포넌트 (v2-rack-structure와 동일 패턴) + +--- + +## 1. 현황 분석 + +### 1.1 기존 DB 테이블 (참조용, 이미 존재) + +| 테이블 | 역할 | 핵심 컬럼 | +|--------|------|----------| +| `item_info` | 품목 마스터 | id, item_name, item_number, company_code | +| `item_routing_version` | 라우팅 버전 | id, item_code, version_name, company_code | +| `item_routing_detail` | 라우팅 상세 (공정 배정) | id, routing_version_id, seq_no, process_code, company_code | +| `process_mng` | 공정 마스터 | id, process_code, process_name, company_code | + +### 1.2 신규 생성 필요 테이블 + +**`process_work_item`** - 작업 항목 (검사 장비 준비, 외관 검사 등) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | VARCHAR PK | UUID | +| company_code | VARCHAR NOT NULL | 멀티테넌시 | +| routing_detail_id | VARCHAR NOT NULL | item_routing_detail.id FK | +| work_phase | VARCHAR NOT NULL | Config의 phases[].key 값 (예: 'PRE', 'IN', 'POST' 또는 사용자 정의) | +| title | VARCHAR NOT NULL | 항목 제목 (예: 검사 장비 준비) | +| is_required | VARCHAR | 'Y' / 'N' | +| sort_order | INTEGER | 표시 순서 | +| description | TEXT | 비고/설명 | +| created_date | TIMESTAMP | 생성일 | +| updated_date | TIMESTAMP | 수정일 | +| writer | VARCHAR | 작성자 | + +**`process_work_item_detail`** - 작업 항목 상세 (버니어 캘리퍼스 상태 소정 등) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | VARCHAR PK | UUID | +| company_code | VARCHAR NOT NULL | 멀티테넌시 | +| work_item_id | VARCHAR NOT NULL | process_work_item.id FK | +| detail_type | VARCHAR | 'CHECK' / 'INSPECTION' / 'MEASUREMENT' 등 | +| content | VARCHAR NOT NULL | 상세 내용 | +| is_required | VARCHAR | 'Y' / 'N' | +| sort_order | INTEGER | 표시 순서 | +| remark | TEXT | 비고 | +| created_date | TIMESTAMP | 생성일 | +| updated_date | TIMESTAMP | 수정일 | +| writer | VARCHAR | 작성자 | + +### 1.3 데이터 흐름 (5단계 연쇄) + +``` +item_info (품목) + └─→ item_routing_version (라우팅 버전) + └─→ item_routing_detail (공정 배정) ← JOIN → process_mng (공정명) + └─→ process_work_item (작업 항목, phase별) + └─→ process_work_item_detail (상세) +``` + +--- + +## 2. 파일 구조 계획 + +### 2.1 프론트엔드 (컴포넌트 등록) + +``` +frontend/lib/registry/components/v2-process-work-standard/ +├── index.ts # createComponentDefinition +├── types.ts # 타입 정의 +├── config.ts # 기본 설정 +├── ProcessWorkStandardRenderer.tsx # AutoRegisteringComponentRenderer +├── ProcessWorkStandardConfigPanel.tsx # 설정 패널 +├── ProcessWorkStandardComponent.tsx # 메인 UI (좌우 분할) +├── components/ +│ ├── ItemProcessSelector.tsx # 좌측: 품목/라우팅/공정 아코디언 트리 +│ ├── WorkStandardEditor.tsx # 우측: 작업기준 편집 영역 전체 +│ ├── WorkPhaseSection.tsx # Pre/In/Post 섹션 (3회 재사용) +│ ├── WorkItemCard.tsx # 작업 항목 카드 +│ ├── WorkItemDetailList.tsx # 상세 리스트 +│ └── WorkItemAddModal.tsx # 작업 항목 추가/수정 모달 +├── hooks/ +│ ├── useProcessWorkStandard.ts # 전체 데이터 관리 훅 +│ ├── useItemProcessTree.ts # 좌측 트리 데이터 훅 +│ └── useWorkItems.ts # 작업 항목 CRUD 훅 +└── README.md +``` + +### 2.2 백엔드 (API) + +``` +backend-node/src/ +├── routes/processWorkStandardRoutes.ts # 라우트 정의 +└── controllers/processWorkStandardController.ts # 컨트롤러 +``` + +### 2.3 DB 마이그레이션 + +``` +db/migrations/XXX_create_process_work_standard_tables.sql +``` + +--- + +## 3. API 설계 + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/api/process-work-standard/items` | 품목 목록 (라우팅 있는 품목만) | +| GET | `/api/process-work-standard/items/:itemCode/routings` | 품목별 라우팅 버전 + 공정 목록 | +| GET | `/api/process-work-standard/routing-detail/:routingDetailId/work-items` | 공정별 작업 항목 목록 (phase별 그룹) | +| POST | `/api/process-work-standard/work-items` | 작업 항목 추가 | +| PUT | `/api/process-work-standard/work-items/:id` | 작업 항목 수정 | +| DELETE | `/api/process-work-standard/work-items/:id` | 작업 항목 삭제 | +| GET | `/api/process-work-standard/work-items/:workItemId/details` | 작업 항목 상세 목록 | +| POST | `/api/process-work-standard/work-item-details` | 상세 추가 | +| PUT | `/api/process-work-standard/work-item-details/:id` | 상세 수정 | +| DELETE | `/api/process-work-standard/work-item-details/:id` | 상세 삭제 | +| PUT | `/api/process-work-standard/save-all` | 전체 저장 (작업 항목 + 상세 일괄) | + +--- + +## 4. 구현 단계 (TDD 기반) + +### Phase 1: DB + API 기반 + +- [ ] 1-1. 마이그레이션 SQL 작성 (process_work_item, process_work_item_detail) +- [ ] 1-2. 마이그레이션 실행 및 테이블 생성 확인 +- [ ] 1-3. 백엔드 라우트/컨트롤러 작성 (CRUD API) +- [ ] 1-4. API 테스트 (품목 목록, 라우팅 조회, 작업항목 CRUD) + +### Phase 2: 컴포넌트 기본 구조 + +- [ ] 2-1. types.ts, config.ts, index.ts 작성 (컴포넌트 정의) +- [ ] 2-2. Renderer, ConfigPanel 작성 (V2 시스템 등록) +- [ ] 2-3. components/index.ts에 import 추가 +- [ ] 2-4. getComponentConfigPanel.tsx에 매핑 추가 +- [ ] 2-5. 화면 디자이너에서 컴포넌트 배치 가능 확인 + +### Phase 3: 좌측 패널 (품목/공정 선택) + +- [ ] 3-1. useItemProcessTree 훅 구현 (품목 목록 + 라우팅 조회) +- [ ] 3-2. ItemProcessSelector 컴포넌트 (아코디언 + 공정 리스트) +- [ ] 3-3. 검색 기능 (품목명/공정명 검색) +- [ ] 3-4. 선택 상태 관리 + 우측 패널 연동 + +### Phase 4: 우측 패널 (작업기준 편집) + +- [ ] 4-1. WorkStandardEditor 기본 레이아웃 (Pre/In/Post 3단 섹션) +- [ ] 4-2. useWorkItems 훅 (작업 항목 + 상세 CRUD) +- [ ] 4-3. WorkPhaseSection 컴포넌트 (섹션 헤더 + 카드 영역 + 상세 영역) +- [ ] 4-4. WorkItemCard 컴포넌트 (카드 UI + 카운트 배지) +- [ ] 4-5. WorkItemDetailList 컴포넌트 (상세 목록 + 인라인 편집) +- [ ] 4-6. WorkItemAddModal (작업 항목 추가/수정 모달 + 상세 추가) + +### Phase 5: 통합 + 전체 저장 + +- [ ] 5-1. 전체 저장 기능 (변경사항 일괄 저장 API 연동) +- [ ] 5-2. 공정 선택 시 데이터 로딩/전환 처리 +- [ ] 5-3. Empty State 처리 (데이터 없을 때 안내 UI) +- [ ] 5-4. 로딩/에러 상태 처리 + +### Phase 6: 마무리 + +- [ ] 6-1. 멀티테넌시 검증 (company_code 필터링) +- [ ] 6-2. 반응형 디자인 점검 +- [ ] 6-3. README.md 작성 + +--- + +## 5. 핵심 UI 설계 + +### 5.1 전체 레이아웃 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ v2-process-work-standard │ +├────────────────────┬────────────────────────────────────────────────┤ +│ 품목 및 공정 선택 │ [품목명] - [공정명] [전체 저장] │ +│ │ │ +│ [검색 입력] │ ── 작업 전 (Pre-Work) N개 항목 ── [+항목추가] │ +│ │ ┌────────┐ ┌─────────────────────────────┐ │ +│ ▼ 볼트 M8x20 │ │카드 │ │ 상세 리스트 (선택 시 표시) │ │ +│ ★ 기본 라우팅 │ │ │ │ │ │ +│ ◉ 재단 │ └────────┘ └─────────────────────────────┘ │ +│ ◉ 검사 ← 선택 │ │ +│ ★ 버전2 │ ── 작업 중 (In-Work) N개 항목 ── [+항목추가] │ +│ │ ┌────────┐ ┌────────┐ │ +│ ▶ 기어 50T │ │카드1 │ │카드2 │ (상세: 우측 표시) │ +│ ▶ 샤프트 D30 │ └────────┘ └────────┘ │ +│ │ │ +│ │ ── 작업 후 (Post-Work) N개 항목 ── [+항목추가] │ +│ │ (동일 구조) │ +├────────────────────┴────────────────────────────────────────────────┤ +│ 30% │ 70% │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 WorkPhaseSection 내부 구조 + +``` +── 작업 전 (Pre-Work) 4개 항목 ────────────────── [+ 작업항목 추가] +┌──────────────────────────────┬──────────────────────────────────────┐ +│ 작업 항목 카드 목록 │ 선택된 항목 상세 │ +│ │ │ +│ ┌──────────────────────┐ │ [항목 제목] [+ 상세추가]│ +│ │ ≡ 검사 장비 준비 ✏️ 🗑 │ │ ─────────────────────────────────── │ +│ │ 4개 필수 │ │ 순서│유형 │내용 │필수│관리│ +│ └──────────────────────┘ │ 1 │체크 │버니어 캘리퍼스... │필수│✏️🗑│ +│ │ 2 │체크 │마이크로미터... │선택│✏️🗑│ +│ ┌──────────────────────┐ │ 3 │체크 │검사대 청소 │선택│✏️🗑│ +│ │ ≡ 측정 도구 확인 ✏️ 🗑 │ │ 4 │체크 │검사 기록지 준비 │필수│✏️🗑│ +│ │ 2개 선택 │ │ │ +│ └──────────────────────┘ │ │ +└──────────────────────────────┴──────────────────────────────────────┘ +``` + +### 5.3 작업 항목 추가 모달 + +``` +┌─────────────────────────────────────────────┐ +│ 작업 항목 추가 ✕ │ +├─────────────────────────────────────────────┤ +│ 기본 정보 │ +│ │ +│ 항목 제목 * 필수 여부 │ +│ [ ] [필수 ▼] │ +│ │ +│ 비고 │ +│ [ ] │ +│ │ +│ 상세 항목 [+ 상세 추가] │ +│ ┌───┬──────┬──────────────┬────┬────┐ │ +│ │순서│유형 │내용 │필수│관리│ │ +│ ├───┼──────┼──────────────┼────┼────┤ │ +│ │ 1 │체크 │ │필수│ 🗑 │ │ +│ └───┴──────┴──────────────┴────┴────┘ │ +│ │ +│ [취소] [저장] │ +└─────────────────────────────────────────────┘ +``` + +--- + +## 6. 컴포넌트 Config 설계 + +### 6.1 설정 패널 UI 구조 + +``` +┌─────────────────────────────────────────────────┐ +│ 공정 작업기준 설정 │ +├─────────────────────────────────────────────────┤ +│ │ +│ ── 데이터 소스 설정 ────────────────────────── │ +│ │ +│ 품목 테이블 │ +│ [item_info ▼] │ +│ 품목명 컬럼 품목코드 컬럼 │ +│ [item_name ▼] [item_number ▼] │ +│ │ +│ 라우팅 버전 테이블 │ +│ [item_routing_version ▼] │ +│ 품목 연결 컬럼 (FK) │ +│ [item_code ▼] │ +│ │ +│ 라우팅 상세 테이블 │ +│ [item_routing_detail ▼] │ +│ │ +│ 공정 마스터 테이블 │ +│ [process_mng ▼] │ +│ │ +│ ── 작업 단계 설정 ────────────────────────── │ +│ │ +│ ┌────┬────────────────────┬─────────────┬───┐ │ +│ │순서│ 단계 키(DB저장용) │ 표시 이름 │관리│ │ +│ ├────┼────────────────────┼─────────────┼───┤ │ +│ │ 1 │ PRE │ 작업 전 │ 🗑 │ │ +│ │ 2 │ IN │ 작업 중 │ 🗑 │ │ +│ │ 3 │ POST │ 작업 후 │ 🗑 │ │ +│ └────┴────────────────────┴─────────────┴───┘ │ +│ [+ 단계 추가] │ +│ │ +│ ── 상세 유형 옵션 ────────────────────────── │ +│ │ +│ ┌────────────────────┬─────────────┬───┐ │ +│ │ 유형 값(DB저장용) │ 표시 이름 │관리│ │ +│ ├────────────────────┼─────────────┼───┤ │ +│ │ CHECK │ 체크 │ 🗑 │ │ +│ │ INSPECTION │ 검사 │ 🗑 │ │ +│ │ MEASUREMENT │ 측정 │ 🗑 │ │ +│ └────────────────────┴─────────────┴───┘ │ +│ [+ 유형 추가] │ +│ │ +│ ── UI 설정 ────────────────────────── │ +│ │ +│ 좌우 분할 비율 │ +│ [30 ] % │ +│ │ +│ 좌측 패널 제목 │ +│ [품목 및 공정 선택 ] │ +│ │ +│ 읽기 전용 모드 │ +│ [ ] 활성화 │ +│ │ +└─────────────────────────────────────────────────┘ +``` + +### 6.2 Config 타입 정의 + +```typescript +// 작업 단계 정의 (사용자가 추가/삭제/이름변경 가능) +interface WorkPhaseDefinition { + key: string; // DB 저장용 키 (예: "PRE", "IN", "POST", "QC") + label: string; // 화면 표시명 (예: "작업 전 (Pre-Work)") + sortOrder: number; // 표시 순서 +} + +// 상세 유형 정의 (사용자가 추가/삭제 가능) +interface DetailTypeDefinition { + value: string; // DB 저장용 값 (예: "CHECK") + label: string; // 화면 표시명 (예: "체크") +} + +// 데이터 소스 설정 (사용자가 테이블 지정 가능) +interface DataSourceConfig { + // 품목 테이블 + itemTable: string; // 기본: "item_info" + itemNameColumn: string; // 기본: "item_name" + itemCodeColumn: string; // 기본: "item_number" + + // 라우팅 버전 테이블 + routingVersionTable: string; // 기본: "item_routing_version" + routingItemFkColumn: string; // 기본: "item_code" (품목과 연결하는 FK) + routingVersionNameColumn: string; // 기본: "version_name" + + // 라우팅 상세 테이블 + routingDetailTable: string; // 기본: "item_routing_detail" + + // 공정 마스터 테이블 + processTable: string; // 기본: "process_mng" + processNameColumn: string; // 기본: "process_name" + processCodeColumn: string; // 기본: "process_code" +} + +// 전체 Config +interface ProcessWorkStandardConfig { + // 데이터 소스 설정 + dataSource: DataSourceConfig; + + // 작업 단계 정의 (기본 3개, 사용자가 추가/삭제/수정 가능) + phases: WorkPhaseDefinition[]; + // 기본값: [ + // { key: "PRE", label: "작업 전 (Pre-Work)", sortOrder: 1 }, + // { key: "IN", label: "작업 중 (In-Work)", sortOrder: 2 }, + // { key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 }, + // ] + + // 상세 유형 옵션 (사용자가 추가/삭제 가능) + detailTypes: DetailTypeDefinition[]; + // 기본값: [ + // { value: "CHECK", label: "체크" }, + // { value: "INSPECTION", label: "검사" }, + // { value: "MEASUREMENT", label: "측정" }, + // ] + + // UI 설정 + splitRatio?: number; // 좌우 분할 비율, 기본: 30 + leftPanelTitle?: string; // 좌측 패널 제목, 기본: "품목 및 공정 선택" + readonly?: boolean; // 읽기 전용 모드, 기본: false +} +``` + +### 6.3 커스터마이징 시나리오 예시 + +**시나리오 A: 제조업 (기본)** +``` +단계: 작업 전 → 작업 중 → 작업 후 +유형: 체크, 검사, 측정 +``` + +**시나리오 B: 품질검사 강화 회사** +``` +단계: 준비 → 검사 → 판정 → 기록 → 보관 +유형: 육안검사, 치수검사, 강도검사, 내구검사, 기능검사 +``` + +**시나리오 C: 단순 2단계 회사** +``` +단계: 사전점검 → 사후점검 +유형: 확인, 기록 +``` + +**시나리오 D: 다른 테이블 사용 회사** +``` +품목 테이블: product_master (item_info 대신) +공정 테이블: operation_mng (process_mng 대신) +``` + +### 6.4 DB 설계 반영 사항 + +`work_phase` 컬럼은 고정 ENUM이 아니라 **사용자 정의 키(VARCHAR)** 로 저장합니다. +- Config에서 `phases[].key` 로 정의한 값이 DB에 저장됨 +- 예: "PRE", "IN", "POST" 또는 "PREPARE", "INSPECT", "JUDGE", "RECORD", "STORE" +- 회사별 Config에 따라 다른 값이 저장되므로, 조회 시 Config의 phases 정의를 기준으로 섹션을 렌더링 + +--- + +## 7. 등록 체크리스트 + +| 항목 | 파일 | 작업 | +|------|------|------| +| 컴포넌트 정의 | `v2-process-work-standard/index.ts` | createComponentDefinition | +| 렌더러 등록 | `v2-process-work-standard/...Renderer.tsx` | registerSelf() | +| 컴포넌트 로드 | `components/index.ts` | import 추가 | +| 설정 패널 매핑 | `getComponentConfigPanel.tsx` | CONFIG_PANEL_MAP 추가 | +| 라우트 등록 | `backend-node/src/app.ts` | router.use() 추가 | + +--- + +## 8. 의존성 + +- 외부 라이브러리 추가: 없음 (기존 shadcn/ui + Lucide 아이콘만 사용) +- 기존 API 재사용: dataRoutes의 범용 CRUD는 사용하지 않고 전용 API 개발 + - 이유: 5단계 JOIN + phase별 그룹핑 등 범용 API로는 처리 불가 diff --git a/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md b/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md index e32e68cc..b37abf5e 100644 --- a/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md +++ b/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md @@ -2,8 +2,8 @@ > **목적**: 다양한 회사에서 V2 컴포넌트를 활용하여 화면을 개발할 때 참고하는 범용 가이드 > **대상**: 화면 설계자, 개발자 -> **버전**: 1.0.0 -> **작성일**: 2026-01-30 +> **버전**: 1.1.0 +> **작성일**: 2026-02-23 (최종 업데이트) --- @@ -19,60 +19,63 @@ | 카드 뷰 | 이미지+정보 카드 형태 | 설비정보, 대시보드 | | 피벗 분석 | 다차원 집계 | 매출분석, 재고현황 | | 반복 컨테이너 | 데이터 수만큼 UI 반복 | 주문 상세, 항목 리스트 | +| 그룹화 테이블 | 그룹핑 기능 포함 테이블 | 카테고리별 집계, 부서별 현황 | +| 타임라인/스케줄 | 시간축 기반 일정 관리 | 생산일정, 작업스케줄 | ### 1.2 불가능한 화면 유형 (별도 개발 필요) | 화면 유형 | 이유 | 해결 방안 | |-----------|------|----------| -| 간트 차트 / 타임라인 | 시간축 기반 UI 없음 | 별도 컴포넌트 개발 or 외부 라이브러리 | | 트리 뷰 (계층 구조) | 트리 컴포넌트 미존재 | `v2-tree-view` 개발 필요 | -| 그룹화 테이블 | 그룹핑 기능 미지원 | `v2-grouped-table` 개발 필요 | | 드래그앤드롭 보드 | 칸반 스타일 UI 없음 | 별도 개발 | | 모바일 앱 스타일 | 네이티브 앱 UI | 별도 개발 | | 복잡한 차트 | 기본 집계 외 시각화 | 차트 라이브러리 연동 | +> **참고**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)는 v1.1에서 추가되어 이제 지원됩니다. + --- -## 2. V2 컴포넌트 전체 목록 (23개) +## 2. V2 컴포넌트 전체 목록 (25개) -### 2.1 입력 컴포넌트 (3개) +### 2.1 입력 컴포넌트 (4개) | ID | 이름 | 용도 | 주요 옵션 | |----|------|------|----------| -| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 이메일, 전화번호, URL, 여러 줄 | inputType, required, readonly, maxLength | -| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크박스 | mode, source(distinct/static/code/entity), multiple | -| `v2-date` | 날짜 | 날짜, 시간, 날짜시간, 날짜범위, 월, 연도 | dateType, format, showTime | +| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 슬라이더, 컬러 | inputType(text/number/password/slider/color/button), format(email/tel/url/currency/biz_no), required, readonly, maxLength, min, max, step | +| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크, 태그, 토글, 스왑 | mode(dropdown/combobox/radio/check/tag/tagbox/toggle/swap), source(static/code/db/api/entity/category/distinct/select), searchable, multiple, cascading | +| `v2-date` | 날짜 | 날짜, 시간, 날짜시간 | dateType(date/time/datetime), format, range, minDate, maxDate, showToday | +| `v2-file-upload` | 파일 업로드 | 파일/이미지 업로드 | - | ### 2.2 표시 컴포넌트 (3개) | ID | 이름 | 용도 | 주요 옵션 | |----|------|------|----------| | `v2-text-display` | 텍스트 표시 | 라벨, 제목, 설명 텍스트 | fontSize, fontWeight, color, textAlign | -| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, showImage, columnMapping | +| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, cardSpacing, columnMapping(titleColumn/subtitleColumn/descriptionColumn/imageColumn), cardStyle(imagePosition/imageSize), dataSource(table/static/api) | | `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수, 최대, 최소 | items, filters, layout | -### 2.3 테이블/데이터 컴포넌트 (3개) +### 2.3 테이블/데이터 컴포넌트 (4개) | ID | 이름 | 용도 | 주요 옵션 | |----|------|------|----------| -| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter | -| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector | -| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields, totals, aggregation | +| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter, displayMode(table/card), checkbox, horizontalScroll, linkedFilters, excludeFilter, toolbar, tableStyle, autoLoad | +| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector, title | +| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields(area: row/column/data/filter, summaryType: sum/avg/count/min/max/countDistinct, groupInterval: year/quarter/month/week/day), dataSource(type: table/api/static, joinConfigs, filterConditions) | +| `v2-table-grouped` | 그룹화 테이블 | 그룹핑 기능이 포함된 테이블 | - | -### 2.4 레이아웃 컴포넌트 (8개) +### 2.4 레이아웃 컴포넌트 (7개) | ID | 이름 | 용도 | 주요 옵션 | |----|------|------|----------| -| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation, **displayMode: custom** | -| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs, activeTabId | +| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, minLeftWidth, minRightWidth, syncSelection, panel별: displayMode(list/table/custom), relation(type/foreignKey), editButton, addButton, deleteButton, additionalTabs | +| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs(id/label/order/disabled/components), defaultTab, orientation(horizontal/vertical), allowCloseable, persistSelection | | `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding | | `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow | | `v2-divider-line` | 구분선 | 영역 구분 | orientation, thickness | | `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 반복 렌더링 | dataSourceType, layout, gridColumns | -| `v2-repeater` | 리피터 | 반복 컨트롤 | - | -| `v2-repeat-screen-modal` | 반복 화면 모달 | 모달 반복 | - | +| `v2-repeater` | 리피터 | 반복 컨트롤 (inline/modal) | - | -### 2.5 액션/특수 컴포넌트 (6개) +### 2.5 액션/특수 컴포넌트 (7개) | ID | 이름 | 용도 | 주요 옵션 | |----|------|------|----------| @@ -82,6 +85,7 @@ | `v2-location-swap-selector` | 위치 교환 | 위치 선택/교환 | - | | `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 | - | | `v2-media` | 미디어 | 이미지/동영상 표시 | - | +| `v2-timeline-scheduler` | 타임라인 스케줄러 | 시간축 기반 일정/작업 관리 | - | --- @@ -261,8 +265,26 @@ ], pagination: { enabled: true, - pageSize: 20 - } + pageSize: 20, + showSizeSelector: true, + showPageInfo: true + }, + displayMode: "table", // "table" | "card" + checkbox: { + enabled: true, + multiple: true, + position: "left", + selectAll: true + }, + horizontalScroll: { // 가로 스크롤 설정 + enabled: true, + maxVisibleColumns: 8 + }, + linkedFilters: [], // 연결 필터 (다른 컴포넌트와 연동) + excludeFilter: {}, // 제외 필터 + autoLoad: true, // 자동 데이터 로드 + stickyHeader: false, // 헤더 고정 + autoWidth: true // 자동 너비 조정 } ``` @@ -271,16 +293,44 @@ ```typescript { leftPanel: { - tableName: "마스터_테이블명" + displayMode: "table", // "list" | "table" | "custom" + tableName: "마스터_테이블명", + columns: [], // 컬럼 설정 + editButton: { // 수정 버튼 설정 + enabled: true, + mode: "auto", // "auto" | "modal" + modalScreenId: "" // 모달 모드 시 화면 ID + }, + addButton: { // 추가 버튼 설정 + enabled: true, + mode: "auto", + modalScreenId: "" + }, + deleteButton: { // 삭제 버튼 설정 + enabled: true, + buttonLabel: "삭제", + confirmMessage: "삭제하시겠습니까?" + }, + addModalColumns: [], // 추가 모달 전용 컬럼 + additionalTabs: [] // 추가 탭 설정 }, rightPanel: { + displayMode: "table", tableName: "디테일_테이블명", relation: { - type: "detail", // join | detail | custom - foreignKey: "master_id" // 연결 키 + type: "detail", // "join" | "detail" | "custom" + foreignKey: "master_id", // 연결 키 + leftColumn: "", // 좌측 연결 컬럼 + rightColumn: "", // 우측 연결 컬럼 + keys: [] // 복합 키 } }, - splitRatio: 30 // 좌측 비율 + splitRatio: 30, // 좌측 비율 (0-100) + resizable: true, // 리사이즈 가능 + minLeftWidth: 200, // 좌측 최소 너비 + minRightWidth: 300, // 우측 최소 너비 + syncSelection: true, // 선택 동기화 + autoLoad: true // 자동 로드 } ``` @@ -347,12 +397,12 @@ | 기능 | 상태 | 대안 | |------|------|------| | 트리 뷰 (BOM, 조직도) | ❌ 미지원 | 테이블로 대체 or 별도 개발 | -| 그룹화 테이블 | ❌ 미지원 | 일반 테이블로 대체 or 별도 개발 | -| 간트 차트 | ❌ 미지원 | 별도 개발 필요 | | 드래그앤드롭 정렬 | ❌ 미지원 | 순서 컬럼으로 대체 | | 인라인 편집 | ⚠️ 제한적 | 모달 편집으로 대체 | | 복잡한 차트 | ❌ 미지원 | 외부 라이브러리 연동 | +> **v1.1 업데이트**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)가 추가되어 해당 기능은 이제 지원됩니다. + ### 5.2 권장하지 않는 조합 | 조합 | 이유 | @@ -555,9 +605,10 @@ | 탭 화면 | ✅ 완전 | v2-tabs-widget | | 카드 뷰 | ✅ 완전 | v2-card-display | | 피벗 분석 | ✅ 완전 | v2-pivot-grid | -| 그룹화 테이블 | ❌ 미지원 | 개발 필요 | +| 그룹화 테이블 | ✅ 지원 | v2-table-grouped | +| 타임라인/스케줄 | ✅ 지원 | v2-timeline-scheduler | +| 파일 업로드 | ✅ 지원 | v2-file-upload | | 트리 뷰 | ❌ 미지원 | 개발 필요 | -| 간트 차트 | ❌ 미지원 | 개발 필요 | ### 개발 시 핵심 원칙 diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index 734032f3..d2b288ff 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -44,7 +44,10 @@ export const V2Repeater: React.FC = ({ onRowClick, className, formData: parentFormData, + ...restProps }) => { + // ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용) + const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData; // 설정 병합 const config: V2RepeaterConfig = useMemo( () => ({ @@ -681,6 +684,15 @@ export const V2Repeater: React.FC = ({ case "fixed": return col.autoFill.fixedValue ?? ""; + case "parentSequence": { + const parentField = col.autoFill.parentField; + const separator = col.autoFill.separator ?? "-"; + const seqLength = col.autoFill.sequenceLength ?? 2; + const parentValue = parentField && mainFormData ? String(mainFormData[parentField] ?? "") : ""; + const seqNum = String(rowIndex + 1).padStart(seqLength, "0"); + return parentValue ? `${parentValue}${separator}${seqNum}` : seqNum; + } + default: return undefined; } @@ -707,7 +719,74 @@ export const V2Repeater: React.FC = ({ [], ); - // 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경 + // 모달에서 전달된 groupedData를 초기 행 데이터로 변환 (컬럼 매핑 포함) + const groupedDataProcessedRef = useRef(false); + useEffect(() => { + if (!groupedData || !Array.isArray(groupedData) || groupedData.length === 0) return; + if (groupedDataProcessedRef.current) return; + + groupedDataProcessedRef.current = true; + + const newRows = groupedData.map((item: any, index: number) => { + const row: any = { _id: `grouped_${Date.now()}_${index}` }; + + for (const col of config.columns) { + const sourceValue = item[(col as any).sourceKey || col.key]; + + if (col.isSourceDisplay) { + row[col.key] = sourceValue ?? ""; + row[`_display_${col.key}`] = sourceValue ?? ""; + } else if (col.autoFill && col.autoFill.type !== "none") { + const autoValue = generateAutoFillValueSync(col, index, parentFormData); + if (autoValue !== undefined) { + row[col.key] = autoValue; + } else { + row[col.key] = ""; + } + } else if (sourceValue !== undefined) { + row[col.key] = sourceValue; + } else { + row[col.key] = ""; + } + } + return row; + }); + + setData(newRows); + onDataChange?.(newRows); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groupedData, config.columns, generateAutoFillValueSync]); + + // parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신 + useEffect(() => { + if (data.length === 0) return; + + const parentSeqColumns = config.columns.filter( + (col) => col.autoFill?.type === "parentSequence" && col.autoFill.parentField, + ); + if (parentSeqColumns.length === 0) return; + + let needsUpdate = false; + const updatedData = data.map((row, index) => { + const updatedRow = { ...row }; + for (const col of parentSeqColumns) { + const newValue = generateAutoFillValueSync(col, index, parentFormData); + if (newValue !== undefined && newValue !== row[col.key]) { + updatedRow[col.key] = newValue; + needsUpdate = true; + } + } + return updatedRow; + }); + + if (needsUpdate) { + setData(updatedData); + onDataChange?.(updatedData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parentFormData, config.columns, generateAutoFillValueSync]); + + // 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경 const handleAddRow = useCallback(async () => { if (isModalMode) { setModalOpen(true); @@ -717,7 +796,7 @@ export const V2Repeater: React.FC = ({ // 먼저 동기적 자동 입력 값 적용 for (const col of config.columns) { - const autoValue = generateAutoFillValueSync(col, currentRowCount); + const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData); if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) { // 채번 규칙: 즉시 API 호출 newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId); @@ -731,7 +810,7 @@ export const V2Repeater: React.FC = ({ const newData = [...data, newRow]; handleDataChange(newData); } - }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode]); + }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData]); // 모달에서 항목 선택 - 비동기로 변경 const handleSelectItems = useCallback( @@ -760,7 +839,7 @@ export const V2Repeater: React.FC = ({ row[`_display_${col.key}`] = item[col.key] || ""; } else { // 자동 입력 값 적용 - const autoValue = generateAutoFillValueSync(col, currentRowCount + index); + const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData); if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) { // 채번 규칙: 즉시 API 호출 row[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId); @@ -789,6 +868,7 @@ export const V2Repeater: React.FC = ({ handleDataChange, generateAutoFillValueSync, generateNumberingCode, + parentFormData, ], ); diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 71eef64c..5b5b5fc2 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -177,6 +177,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ { value: "numbering", label: "채번 규칙" }, { value: "fromMainForm", label: "메인 폼에서 복사" }, { value: "fixed", label: "고정값" }, + { value: "parentSequence", label: "부모채번+순번 (예: WO-001-01)" }, ]; // 🆕 대상 메뉴 목록 로드 (사용자 메뉴의 레벨 2) @@ -1393,6 +1394,56 @@ export const V2RepeaterConfigPanel: React.FC = ({ /> )} + + {/* 부모채번+순번 설정 */} + {col.autoFill?.type === "parentSequence" && ( +
+
+ + updateColumnProp(col.key, "autoFill", { + ...col.autoFill, + parentField: e.target.value, + })} + placeholder="work_order_no" + className="h-6 text-xs" + /> +

메인 폼에서 가져올 부모 채번 필드

+
+
+
+ + updateColumnProp(col.key, "autoFill", { + ...col.autoFill, + separator: e.target.value, + })} + placeholder="-" + className="h-6 text-xs" + /> +
+
+ + updateColumnProp(col.key, "autoFill", { + ...col.autoFill, + sequenceLength: parseInt(e.target.value) || 2, + })} + className="h-6 text-xs" + /> +
+
+

+ 예시: WO-20260223-005{col.autoFill?.separator ?? "-"}{String(1).padStart(col.autoFill?.sequenceLength ?? 2, "0")} +

+
+ )} )} diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index e6b13067..ff0285a2 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -466,7 +466,8 @@ export const DynamicComponentRenderer: React.FC = let currentValue; if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal" || - componentType === "selected-items-detail-input") { + componentType === "selected-items-detail-input" || + componentType === "v2-repeater") { // EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용 currentValue = props.groupedData || formData?.[fieldName] || []; } else { diff --git a/frontend/types/v2-repeater.ts b/frontend/types/v2-repeater.ts index fab7a523..3b9ca1df 100644 --- a/frontend/types/v2-repeater.ts +++ b/frontend/types/v2-repeater.ts @@ -24,7 +24,8 @@ export type AutoFillType = | "sequence" // 순번 (1, 2, 3...) | "numbering" // 채번 규칙 (관리자가 등록한 규칙 선택) | "fromMainForm" // 메인 폼에서 값 복사 - | "fixed"; // 고정값 + | "fixed" // 고정값 + | "parentSequence"; // 부모 채번 + 순번 (예: WO-20260223-005-01) // 자동 입력 설정 export interface AutoFillConfig { @@ -36,6 +37,10 @@ export interface AutoFillConfig { // numbering 타입용 - 기존 채번 규칙 ID를 참조 numberingRuleId?: string; // 채번 규칙 ID (numbering_rules 테이블) selectedMenuObjid?: number; // 🆕 채번 규칙 선택을 위한 대상 메뉴 OBJID + // parentSequence 타입용 + parentField?: string; // 메인 폼에서 부모 번호를 가져올 필드명 + separator?: string; // 부모 번호와 순번 사이 구분자 (기본: "-") + sequenceLength?: number; // 순번 자릿수 (기본: 2 → 01, 02, 03) } // 컬럼 설정 From 4f6d9a689d62d734fe62f58c544da5fc14e24fd4 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 24 Feb 2026 12:37:33 +0900 Subject: [PATCH 2/2] feat: Implement process work standard routes and controller - Added a new controller for managing process work standards, including CRUD operations for work items and routing processes. - Introduced routes for fetching items with routing, retrieving routings with processes, and managing work items. - Integrated the new process work standard routes into the main application file for API accessibility. - Created a migration script for exporting data related to the new process work standard feature. - Updated frontend components to support the new process work standard functionality, enhancing the overall user experience. --- backend-node/src/app.ts | 2 + .../processWorkStandardController.ts | 573 ++++++++++++++++++ .../src/routes/processWorkStandardRoutes.ts | 29 + db/migrate_company13_export.sh | 149 +++++ docker/deploy/docker-compose.yml | 2 +- docs/formdata-console-log-test-guide.md | 78 +++ frontend/components/common/ScreenModal.tsx | 15 +- frontend/lib/registry/components/index.ts | 1 + .../SplitPanelLayoutComponent.tsx | 51 ++ .../ButtonPrimaryComponent.tsx | 9 + .../ProcessWorkStandardComponent.tsx | 241 ++++++++ .../ProcessWorkStandardConfigPanel.tsx | 282 +++++++++ .../ProcessWorkStandardRenderer.tsx | 32 + .../components/ItemProcessSelector.tsx | 167 +++++ .../components/WorkItemAddModal.tsx | 337 ++++++++++ .../components/WorkItemCard.tsx | 88 +++ .../components/WorkItemDetailList.tsx | 380 ++++++++++++ .../components/WorkPhaseSection.tsx | 123 ++++ .../v2-process-work-standard/config.ts | 33 + .../hooks/useProcessWorkStandard.ts | 336 ++++++++++ .../v2-process-work-standard/index.ts | 59 ++ .../v2-process-work-standard/types.ts | 111 ++++ .../lib/utils/getComponentConfigPanel.tsx | 1 + frontend/package-lock.json | 48 ++ frontend/package.json | 1 + frontend/scripts/test-formdata-logs.ts | 135 +++++ 26 files changed, 3279 insertions(+), 4 deletions(-) create mode 100644 backend-node/src/controllers/processWorkStandardController.ts create mode 100644 backend-node/src/routes/processWorkStandardRoutes.ts create mode 100755 db/migrate_company13_export.sh create mode 100644 docs/formdata-console-log-test-guide.md create mode 100644 frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx create mode 100644 frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-process-work-standard/components/ItemProcessSelector.tsx create mode 100644 frontend/lib/registry/components/v2-process-work-standard/components/WorkItemAddModal.tsx create mode 100644 frontend/lib/registry/components/v2-process-work-standard/components/WorkItemCard.tsx create mode 100644 frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx create mode 100644 frontend/lib/registry/components/v2-process-work-standard/components/WorkPhaseSection.tsx create mode 100644 frontend/lib/registry/components/v2-process-work-standard/config.ts create mode 100644 frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts create mode 100644 frontend/lib/registry/components/v2-process-work-standard/index.ts create mode 100644 frontend/lib/registry/components/v2-process-work-standard/types.ts create mode 100644 frontend/scripts/test-formdata-logs.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 30e684d5..e454742a 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -123,6 +123,7 @@ import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRou import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리 import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계 import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트) +import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -304,6 +305,7 @@ app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리 app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계 app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트) +app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts new file mode 100644 index 00000000..6b663e2b --- /dev/null +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -0,0 +1,573 @@ +/** + * 공정 작업기준 컨트롤러 + * 품목별 라우팅/공정에 대한 작업 항목 및 상세 관리 + */ + +import { Request, Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ============================================================ +// 품목/라우팅/공정 조회 (좌측 트리 데이터) +// ============================================================ + +/** + * 라우팅이 있는 품목 목록 조회 + * 요청 쿼리: tableName(품목테이블), nameColumn, codeColumn + */ +export async function getItemsWithRouting(req: Request, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { + tableName = "item_info", + nameColumn = "item_name", + codeColumn = "item_number", + routingTable = "item_routing_version", + routingFkColumn = "item_code", + search = "", + } = req.query as Record; + + const searchCondition = search + ? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)` + : ""; + const params: any[] = [companyCode]; + if (search) params.push(`%${search}%`); + + const query = ` + SELECT DISTINCT + i.id, + i.${nameColumn} AS item_name, + i.${codeColumn} AS item_code + FROM ${tableName} i + INNER JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} + AND rv.company_code = i.company_code + WHERE i.company_code = $1 + ${searchCondition} + ORDER BY i.${codeColumn} + `; + + const result = await getPool().query(query, params); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("품목 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 품목별 라우팅 버전 + 공정 목록 조회 (트리 하위 데이터) + */ +export async function getRoutingsWithProcesses(req: Request, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { itemCode } = req.params; + const { + routingVersionTable = "item_routing_version", + routingDetailTable = "item_routing_detail", + routingFkColumn = "item_code", + processTable = "process_mng", + processNameColumn = "process_name", + processCodeColumn = "process_code", + } = req.query as Record; + + // 라우팅 버전 목록 + const versionsQuery = ` + SELECT id, version_name, description, created_date + FROM ${routingVersionTable} + WHERE ${routingFkColumn} = $1 AND company_code = $2 + ORDER BY created_date DESC + `; + const versionsResult = await getPool().query(versionsQuery, [ + itemCode, + companyCode, + ]); + + // 각 버전별 공정 목록 + const routings = []; + for (const version of versionsResult.rows) { + const detailsQuery = ` + SELECT + rd.id AS routing_detail_id, + rd.seq_no, + rd.process_code, + rd.is_required, + rd.work_type, + p.${processNameColumn} AS process_name + FROM ${routingDetailTable} rd + LEFT JOIN ${processTable} p ON p.${processCodeColumn} = rd.process_code + AND p.company_code = rd.company_code + WHERE rd.routing_version_id = $1 AND rd.company_code = $2 + ORDER BY rd.seq_no::integer + `; + const detailsResult = await getPool().query(detailsQuery, [ + version.id, + companyCode, + ]); + + routings.push({ + ...version, + processes: detailsResult.rows, + }); + } + + return res.json({ success: true, data: routings }); + } catch (error: any) { + logger.error("라우팅/공정 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================================ +// 작업 항목 CRUD +// ============================================================ + +/** + * 공정별 작업 항목 목록 조회 (phase별 그룹) + */ +export async function getWorkItems(req: Request, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { routingDetailId } = req.params; + + const query = ` + SELECT + wi.id, + wi.routing_detail_id, + wi.work_phase, + wi.title, + wi.is_required, + wi.sort_order, + wi.description, + wi.created_date, + (SELECT COUNT(*) FROM process_work_item_detail d + WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code + )::integer AS detail_count + FROM process_work_item wi + WHERE wi.routing_detail_id = $1 AND wi.company_code = $2 + ORDER BY wi.work_phase, wi.sort_order, wi.created_date + `; + + const result = await getPool().query(query, [routingDetailId, companyCode]); + + // phase별 그룹핑 + const grouped: Record = {}; + for (const row of result.rows) { + const phase = row.work_phase; + if (!grouped[phase]) grouped[phase] = []; + grouped[phase].push(row); + } + + return res.json({ success: true, data: grouped, items: result.rows }); + } catch (error: any) { + logger.error("작업 항목 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 작업 항목 추가 + */ +export async function createWorkItem(req: Request, res: Response) { + try { + const companyCode = req.user?.companyCode; + const writer = req.user?.userId; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { routing_detail_id, work_phase, title, is_required, sort_order, description } = req.body; + + if (!routing_detail_id || !work_phase || !title) { + return res.status(400).json({ + success: false, + message: "routing_detail_id, work_phase, title은 필수입니다", + }); + } + + const query = ` + INSERT INTO process_work_item + (company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + `; + + const result = await getPool().query(query, [ + companyCode, + routing_detail_id, + work_phase, + title, + is_required || "N", + sort_order || 0, + description || null, + writer, + ]); + + logger.info("작업 항목 생성", { companyCode, id: result.rows[0].id }); + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("작업 항목 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 작업 항목 수정 + */ +export async function updateWorkItem(req: Request, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { id } = req.params; + const { title, is_required, sort_order, description } = req.body; + + const query = ` + UPDATE process_work_item + SET title = COALESCE($1, title), + is_required = COALESCE($2, is_required), + sort_order = COALESCE($3, sort_order), + description = COALESCE($4, description), + updated_date = NOW() + WHERE id = $5 AND company_code = $6 + RETURNING * + `; + + const result = await getPool().query(query, [ + title, + is_required, + sort_order, + description, + id, + companyCode, + ]); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" }); + } + + logger.info("작업 항목 수정", { companyCode, id }); + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("작업 항목 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 작업 항목 삭제 (상세도 함께 삭제) + */ +export async function deleteWorkItem(req: Request, res: Response) { + const client = await getPool().connect(); + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { id } = req.params; + + await client.query("BEGIN"); + + // 상세 먼저 삭제 + await client.query( + "DELETE FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2", + [id, companyCode] + ); + + // 항목 삭제 + const result = await client.query( + "DELETE FROM process_work_item WHERE id = $1 AND company_code = $2 RETURNING id", + [id, companyCode] + ); + + if (result.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" }); + } + + await client.query("COMMIT"); + logger.info("작업 항목 삭제", { companyCode, id }); + return res.json({ success: true }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("작업 항목 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +// ============================================================ +// 작업 항목 상세 CRUD +// ============================================================ + +/** + * 작업 항목 상세 목록 조회 + */ +export async function getWorkItemDetails(req: Request, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { workItemId } = req.params; + + const query = ` + SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, created_date + FROM process_work_item_detail + WHERE work_item_id = $1 AND company_code = $2 + ORDER BY sort_order, created_date + `; + + const result = await getPool().query(query, [workItemId, companyCode]); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("작업 항목 상세 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 작업 항목 상세 추가 + */ +export async function createWorkItemDetail(req: Request, res: Response) { + try { + const companyCode = req.user?.companyCode; + const writer = req.user?.userId; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { work_item_id, detail_type, content, is_required, sort_order, remark } = req.body; + + if (!work_item_id || !content) { + return res.status(400).json({ + success: false, + message: "work_item_id, content는 필수입니다", + }); + } + + // work_item이 같은 company_code인지 검증 + const ownerCheck = await getPool().query( + "SELECT id FROM process_work_item WHERE id = $1 AND company_code = $2", + [work_item_id, companyCode] + ); + if (ownerCheck.rowCount === 0) { + return res.status(403).json({ success: false, message: "권한이 없습니다" }); + } + + const query = ` + INSERT INTO process_work_item_detail + (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + `; + + const result = await getPool().query(query, [ + companyCode, + work_item_id, + detail_type || null, + content, + is_required || "N", + sort_order || 0, + remark || null, + writer, + ]); + + logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id }); + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("작업 항목 상세 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 작업 항목 상세 수정 + */ +export async function updateWorkItemDetail(req: Request, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { id } = req.params; + const { detail_type, content, is_required, sort_order, remark } = req.body; + + const query = ` + UPDATE process_work_item_detail + SET detail_type = COALESCE($1, detail_type), + content = COALESCE($2, content), + is_required = COALESCE($3, is_required), + sort_order = COALESCE($4, sort_order), + remark = COALESCE($5, remark), + updated_date = NOW() + WHERE id = $6 AND company_code = $7 + RETURNING * + `; + + const result = await getPool().query(query, [ + detail_type, + content, + is_required, + sort_order, + remark, + id, + companyCode, + ]); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" }); + } + + logger.info("작업 항목 상세 수정", { companyCode, id }); + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("작업 항목 상세 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 작업 항목 상세 삭제 + */ +export async function deleteWorkItemDetail(req: Request, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { id } = req.params; + + const result = await getPool().query( + "DELETE FROM process_work_item_detail WHERE id = $1 AND company_code = $2 RETURNING id", + [id, companyCode] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" }); + } + + logger.info("작업 항목 상세 삭제", { companyCode, id }); + return res.json({ success: true }); + } catch (error: any) { + logger.error("작업 항목 상세 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================================ +// 전체 저장 (일괄) +// ============================================================ + +/** + * 전체 저장: 작업 항목 + 상세를 일괄 저장 + * 기존 데이터를 삭제하고 새로 삽입하는 replace 방식 + */ +export async function saveAll(req: Request, res: Response) { + const client = await getPool().connect(); + try { + const companyCode = req.user?.companyCode; + const writer = req.user?.userId; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { routing_detail_id, items } = req.body; + + if (!routing_detail_id || !Array.isArray(items)) { + return res.status(400).json({ + success: false, + message: "routing_detail_id와 items 배열이 필요합니다", + }); + } + + await client.query("BEGIN"); + + // 기존 상세 삭제 + await client.query( + `DELETE FROM process_work_item_detail + WHERE work_item_id IN ( + SELECT id FROM process_work_item + WHERE routing_detail_id = $1 AND company_code = $2 + )`, + [routing_detail_id, companyCode] + ); + + // 기존 항목 삭제 + await client.query( + "DELETE FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2", + [routing_detail_id, companyCode] + ); + + // 새 항목 + 상세 삽입 + for (const item of items) { + const itemResult = await client.query( + `INSERT INTO process_work_item + (company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + [ + companyCode, + routing_detail_id, + item.work_phase, + item.title, + item.is_required || "N", + item.sort_order || 0, + item.description || null, + writer, + ] + ); + + const workItemId = itemResult.rows[0].id; + + if (Array.isArray(item.details)) { + for (const detail of item.details) { + await client.query( + `INSERT INTO process_work_item_detail + (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + companyCode, + workItemId, + detail.detail_type || null, + detail.content, + detail.is_required || "N", + detail.sort_order || 0, + detail.remark || null, + writer, + ] + ); + } + } + } + + await client.query("COMMIT"); + logger.info("작업기준 전체 저장", { companyCode, routing_detail_id, itemCount: items.length }); + return res.json({ success: true, message: "저장 완료" }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("작업기준 전체 저장 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} diff --git a/backend-node/src/routes/processWorkStandardRoutes.ts b/backend-node/src/routes/processWorkStandardRoutes.ts new file mode 100644 index 00000000..087f08c0 --- /dev/null +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -0,0 +1,29 @@ +/** + * 공정 작업기준 라우트 + */ + +import express from "express"; +import * as ctrl from "../controllers/processWorkStandardController"; + +const router = express.Router(); + +// 품목/라우팅/공정 조회 (좌측 트리) +router.get("/items", ctrl.getItemsWithRouting); +router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses); + +// 작업 항목 CRUD +router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems); +router.post("/work-items", ctrl.createWorkItem); +router.put("/work-items/:id", ctrl.updateWorkItem); +router.delete("/work-items/:id", ctrl.deleteWorkItem); + +// 작업 항목 상세 CRUD +router.get("/work-items/:workItemId/details", ctrl.getWorkItemDetails); +router.post("/work-item-details", ctrl.createWorkItemDetail); +router.put("/work-item-details/:id", ctrl.updateWorkItemDetail); +router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail); + +// 전체 저장 (일괄) +router.put("/save-all", ctrl.saveAll); + +export default router; diff --git a/db/migrate_company13_export.sh b/db/migrate_company13_export.sh new file mode 100755 index 00000000..fc96f04a --- /dev/null +++ b/db/migrate_company13_export.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# ============================================================ +# 엘에스티라유텍(주) - 동부지사 (COMPANY_13) 전체 데이터 Export +# +# 사용법: +# 1. SOURCE_* / TARGET_* 변수를 수정 +# 2. chmod +x migrate_company13_export.sh +# 3. ./migrate_company13_export.sh export → SQL 파일 생성 +# 4. ./migrate_company13_export.sh import → 대상 DB에 적재 +# ============================================================ + +SOURCE_HOST="localhost" +SOURCE_PORT="5432" +SOURCE_DB="vexplor" +SOURCE_USER="postgres" + +TARGET_HOST="대상_호스트" +TARGET_PORT="5432" +TARGET_DB="대상_DB명" +TARGET_USER="postgres" + +OUTPUT_FILE="company13_migration_$(date '+%Y%m%d_%H%M%S').sql" + +# 데이터가 있는 테이블 (의존성 순서) +TABLES=( + "company_mng" + "user_info" + "authority_master" + "menu_info" + "external_db_connections" + "external_rest_api_connections" + "screen_definitions" + "screen_groups" + "screen_layouts_v1" + "screen_layouts_v2" + "screen_layouts_v3" + "screen_menu_assignments" + "dashboards" + "dashboard_elements" + "flow_definition" + "node_flows" + "table_column_category_values" + "attach_file_info" + "tax_invoice" + "auth_tokens" + "batch_configs" + "batch_execution_logs" + "batch_mappings" + "digital_twin_layout" + "digital_twin_layout_template" + "dtg_management" + "transport_statistics" + "vehicles" + "vehicle_location_history" +) + +do_export() { + echo "==========================================" + echo " COMPANY_13 데이터 Export 시작" + echo "==========================================" + + cat > "$OUTPUT_FILE" <<'HEADER' +-- ============================================================ +-- 엘에스티라유텍(주) - 동부지사 (COMPANY_13) 전체 데이터 마이그레이션 +-- +-- 총 29개 테이블, 약 11,500건 데이터 +-- +-- 실행 방법: +-- psql -h HOST -U USER -d DATABASE -f 이_파일명.sql +-- ============================================================ + +SET client_encoding TO 'UTF8'; +SET standard_conforming_strings = on; + +BEGIN; + +HEADER + + for TABLE in "${TABLES[@]}"; do + COUNT=$(psql -h "$SOURCE_HOST" -p "$SOURCE_PORT" -U "$SOURCE_USER" -d "$SOURCE_DB" \ + -t -A -c "SELECT COUNT(*) FROM $TABLE WHERE company_code = 'COMPANY_13'") + COUNT=$(echo "$COUNT" | tr -d '[:space:]') + + if [ "$COUNT" -gt 0 ]; then + echo " $TABLE: ${COUNT}건 추출 중..." + + echo "-- ----------------------------------------" >> "$OUTPUT_FILE" + echo "-- $TABLE (${COUNT}건)" >> "$OUTPUT_FILE" + echo "-- ----------------------------------------" >> "$OUTPUT_FILE" + echo "COPY $TABLE FROM stdin;" >> "$OUTPUT_FILE" + + psql -h "$SOURCE_HOST" -p "$SOURCE_PORT" -U "$SOURCE_USER" -d "$SOURCE_DB" \ + -t -A -c "COPY (SELECT * FROM $TABLE WHERE company_code = 'COMPANY_13') TO STDOUT" >> "$OUTPUT_FILE" + + echo "\\." >> "$OUTPUT_FILE" + echo "" >> "$OUTPUT_FILE" + else + echo " $TABLE: 데이터 없음 (건너뜀)" + fi + done + + echo "" >> "$OUTPUT_FILE" + echo "COMMIT;" >> "$OUTPUT_FILE" + echo "" >> "$OUTPUT_FILE" + echo "-- 마이그레이션 완료" >> "$OUTPUT_FILE" + + echo "" + echo "==========================================" + echo " Export 완료: $OUTPUT_FILE" + echo "==========================================" + echo "" + echo "대상 DB에서 실행:" + echo " psql -h $TARGET_HOST -p $TARGET_PORT -U $TARGET_USER -d $TARGET_DB -f $OUTPUT_FILE" +} + +do_import() { + SQL_FILE=$(ls -t company13_migration_*.sql 2>/dev/null | head -1) + + if [ -z "$SQL_FILE" ]; then + echo "마이그레이션 SQL 파일을 찾을 수 없습니다. 먼저 export를 실행하세요." + exit 1 + fi + + echo "==========================================" + echo " COMPANY_13 데이터 Import 시작" + echo " 파일: $SQL_FILE" + echo " 대상: $TARGET_HOST:$TARGET_PORT/$TARGET_DB" + echo "==========================================" + + psql -h "$TARGET_HOST" -p "$TARGET_PORT" -U "$TARGET_USER" -d "$TARGET_DB" -f "$SQL_FILE" + + echo "" + echo "==========================================" + echo " Import 완료" + echo "==========================================" +} + +case "${1:-export}" in + export) + do_export + ;; + import) + do_import + ;; + *) + echo "사용법: $0 {export|import}" + exit 1 + ;; +esac diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index b3cc4996..efd1b961 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -12,7 +12,7 @@ services: NODE_ENV: production PORT: "3001" HOST: 0.0.0.0 - DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm + DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024 JWT_EXPIRES_IN: 24h CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com diff --git a/docs/formdata-console-log-test-guide.md b/docs/formdata-console-log-test-guide.md new file mode 100644 index 00000000..81a47486 --- /dev/null +++ b/docs/formdata-console-log-test-guide.md @@ -0,0 +1,78 @@ +# formData 콘솔 로그 수동 테스트 가이드 + +## 테스트 시나리오 + +1. http://localhost:9771/screens/1599?menuObjid=1762422235300 접속 +2. 로그인 필요 시: `topseal_admin` / `1234` +3. 5초 대기 (페이지 로드) +4. 첫 번째 탭 "공정 마스터" 확인 +5. 좌측 패널에서 **P003** 행 클릭 +6. 우측 패널에서 **추가** 버튼 클릭 +7. 모달에서 설비(equipment) 드롭다운에서 항목 선택 +8. **저장** 버튼 클릭 **전** 콘솔 스냅샷 확인 +9. **저장** 버튼 클릭 **후** 콘솔 로그 확인 + +## 확인할 콘솔 로그 + +### 1. ADD 모드 formData 설정 (ScreenModal) + +``` +🔵 [ScreenModal] ADD모드 formData 설정: {...} +``` + +- **위치**: `frontend/components/common/ScreenModal.tsx` 358행 +- **의미**: 모달이 ADD 모드로 열릴 때 부모 데이터(splitPanelParentData)로 설정된 초기 formData +- **확인**: `process_code`가 P003으로 포함되어 있는지 + +### 2. formData 변경 시 (ScreenModal) + +``` +🟡 [ScreenModal] onFormDataChange: equipment_code → E001 | formData keys: [...] | process_code: P003 +``` + +- **위치**: `frontend/components/common/ScreenModal.tsx` 1184행 +- **의미**: 사용자가 설비를 선택할 때마다 발생 +- **확인**: `process_code`가 유지되는지, `equipment_code`가 추가되는지 + +### 3. 저장 시 formData 디버그 (ButtonPrimary) + +``` +🔴 [ButtonPrimary] 저장 시 formData 디버그: { + propsFormDataKeys: [...], + screenContextFormDataKeys: [...], + effectiveFormDataKeys: [...], + process_code: "P003", + equipment_code: "E001", + fullData: "{...}" +} +``` + +- **위치**: `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` 1110행 +- **의미**: 저장 버튼 클릭 시 실제로 API에 전달되는 formData +- **확인**: `process_code`, `equipment_code`가 모두 포함되어 있는지 + +## 추가로 확인할 로그 + +- `process_code` 포함 로그 +- `splitPanelParentData` 포함 로그 +- `🆕 [추가모달] screenId 기반 모달 열기:` (SplitPanelLayoutComponent 1639행) + +## 에러 확인 + +콘솔에 빨간색으로 표시되는 에러 메시지가 있는지 확인하세요. + +## 사전 조건 + +- **process_mng** 테이블에 P003 데이터가 있어야 함 (company_code = 로그인 사용자 회사) +- **equipment_mng** 테이블에 설비 데이터가 있어야 함 +- 로그인 사용자가 해당 회사(COMPANY_7 등) 권한이 있어야 함 + +## 자동 테스트 스크립트 + +데이터가 준비된 환경에서: + +```bash +cd frontend && npx tsx scripts/test-formdata-logs.ts +``` + +데이터가 없으면 "좌측 테이블에 데이터가 없습니다" 오류가 발생합니다. diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index b6660709..86348d23 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -178,10 +178,17 @@ export const ScreenModal: React.FC = ({ className }) => { splitPanelParentData, selectedData: eventSelectedData, selectedIds, - isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함) - fieldMappings, // 🆕 필드 매핑 정보 (명시적 매핑이 있으면 모든 매핑된 필드 전달) + isCreateMode, + fieldMappings, } = event.detail; + console.log("🟣 [ScreenModal] openScreenModal 이벤트 수신:", { + screenId, + splitPanelParentData: JSON.stringify(splitPanelParentData), + editData: !!editData, + isCreateMode, + }); + // 🆕 모달 열린 시간 기록 modalOpenedAtRef.current = Date.now(); @@ -355,8 +362,10 @@ export const ScreenModal: React.FC = ({ className }) => { } if (Object.keys(parentData).length > 0) { + console.log("🔵 [ScreenModal] ADD모드 formData 설정:", JSON.stringify(parentData)); setFormData(parentData); } else { + console.log("🔵 [ScreenModal] ADD모드 formData 비어있음"); setFormData({}); } setOriginalData(null); // 신규 등록 모드 @@ -1173,13 +1182,13 @@ export const ScreenModal: React.FC = ({ className }) => { formData={formData} originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용) onFormDataChange={(fieldName, value) => { - // 사용자가 실제로 데이터를 변경한 것으로 표시 formDataChangedRef.current = true; setFormData((prev) => { const newFormData = { ...prev, [fieldName]: value, }; + console.log("🟡 [ScreenModal] onFormDataChange:", fieldName, "→", value, "| formData keys:", Object.keys(newFormData), "| process_code:", newFormData.process_code); return newFormData; }); }} diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 172f0067..910f3a0b 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -112,6 +112,7 @@ import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트 import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트 +import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 6264a757..e94b6cce 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1603,6 +1603,57 @@ export const SplitPanelLayoutComponent: React.FC const handleAddClick = useCallback( (panel: "left" | "right") => { console.log("🆕 [추가모달] handleAddClick 호출:", { panel, activeTabIndex }); + + // screenId 기반 모달 확인 + const panelConfig = panel === "left" ? componentConfig.leftPanel : componentConfig.rightPanel; + const addModalConfig = panelConfig?.addModal; + + if (addModalConfig?.screenId) { + if (panel === "right" && !selectedLeftItem) { + toast({ + title: "항목을 선택해주세요", + description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.", + variant: "destructive", + }); + return; + } + + const tableName = panelConfig?.tableName || ""; + const urlParams: Record = { + mode: "add", + tableName, + }; + + const parentData: Record = {}; + if (panel === "right" && selectedLeftItem) { + const relation = componentConfig.rightPanel?.relation; + console.log("🟢 [추가모달] selectedLeftItem:", JSON.stringify(selectedLeftItem)); + console.log("🟢 [추가모달] relation:", JSON.stringify(relation)); + if (relation?.keys && Array.isArray(relation.keys)) { + for (const key of relation.keys) { + console.log("🟢 [추가모달] key:", key, "leftValue:", selectedLeftItem[key.leftColumn]); + if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) { + parentData[key.rightColumn] = selectedLeftItem[key.leftColumn]; + } + } + } + } + + console.log("🆕 [추가모달] screenId 기반 모달 열기:", { screenId: addModalConfig.screenId, tableName, parentData, parentDataStr: JSON.stringify(parentData) }); + + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: addModalConfig.screenId, + urlParams, + splitPanelParentData: parentData, + }, + }), + ); + return; + } + + // 기존 인라인 모달 방식 setAddModalPanel(panel); // 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움 diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 5516a4bf..01461660 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -1107,6 +1107,15 @@ export const ButtonPrimaryComponent: React.FC = ({ effectiveFormData = { ...splitPanelParentData }; } + console.log("🔴 [ButtonPrimary] 저장 시 formData 디버그:", { + propsFormDataKeys: Object.keys(propsFormData), + screenContextFormDataKeys: Object.keys(screenContextFormData), + effectiveFormDataKeys: Object.keys(effectiveFormData), + process_code: effectiveFormData.process_code, + equipment_code: effectiveFormData.equipment_code, + fullData: JSON.stringify(effectiveFormData), + }); + const context: ButtonActionContext = { formData: effectiveFormData, originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용) diff --git a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx new file mode 100644 index 00000000..c859f108 --- /dev/null +++ b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx @@ -0,0 +1,241 @@ +"use client"; + +import React, { useState, useMemo, useCallback } from "react"; +import { Save, Loader2, ClipboardCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { ProcessWorkStandardConfig, WorkItem } from "./types"; +import { defaultConfig } from "./config"; +import { useProcessWorkStandard } from "./hooks/useProcessWorkStandard"; +import { ItemProcessSelector } from "./components/ItemProcessSelector"; +import { WorkPhaseSection } from "./components/WorkPhaseSection"; +import { WorkItemAddModal } from "./components/WorkItemAddModal"; + +interface ProcessWorkStandardComponentProps { + config?: Partial; + formData?: Record; + isPreview?: boolean; + tableName?: string; +} + +export function ProcessWorkStandardComponent({ + config: configProp, + isPreview, +}: ProcessWorkStandardComponentProps) { + const config: ProcessWorkStandardConfig = useMemo( + () => ({ + ...defaultConfig, + ...configProp, + dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource }, + phases: configProp?.phases?.length + ? configProp.phases + : defaultConfig.phases, + detailTypes: configProp?.detailTypes?.length + ? configProp.detailTypes + : defaultConfig.detailTypes, + }), + [configProp] + ); + + const { + items, + routings, + workItems, + selectedWorkItemDetails, + selectedWorkItemId, + selection, + loading, + fetchItems, + selectItem, + selectProcess, + fetchWorkItemDetails, + createWorkItem, + updateWorkItem, + deleteWorkItem, + createDetail, + updateDetail, + deleteDetail, + } = useProcessWorkStandard(config); + + // 모달 상태 + const [modalOpen, setModalOpen] = useState(false); + const [modalPhaseKey, setModalPhaseKey] = useState(""); + const [editingItem, setEditingItem] = useState(null); + + // phase별 작업 항목 그룹핑 + const workItemsByPhase = useMemo(() => { + const map: Record = {}; + for (const phase of config.phases) { + map[phase.key] = workItems.filter((wi) => wi.work_phase === phase.key); + } + return map; + }, [workItems, config.phases]); + + const sortedPhases = useMemo( + () => [...config.phases].sort((a, b) => a.sortOrder - b.sortOrder), + [config.phases] + ); + + const handleAddWorkItem = useCallback((phaseKey: string) => { + setModalPhaseKey(phaseKey); + setEditingItem(null); + setModalOpen(true); + }, []); + + const handleEditWorkItem = useCallback((item: WorkItem) => { + setModalPhaseKey(item.work_phase); + setEditingItem(item); + setModalOpen(true); + }, []); + + const handleModalSave = useCallback( + async (data: Parameters[0]) => { + if (editingItem) { + await updateWorkItem(editingItem.id, { + title: data.title, + is_required: data.is_required, + description: data.description, + } as any); + } else { + await createWorkItem(data); + } + }, + [editingItem, createWorkItem, updateWorkItem] + ); + + const handleSelectWorkItem = useCallback( + (workItemId: string) => { + fetchWorkItemDetails(workItemId); + }, + [fetchWorkItemDetails] + ); + + const handleInit = useCallback(() => { + fetchItems(); + }, [fetchItems]); + + const splitRatio = config.splitRatio || 30; + + if (isPreview) { + return ( +
+
+ +

+ 공정 작업기준 +

+

+ {sortedPhases.map((p) => p.label).join(" / ")} +

+
+
+ ); + } + + return ( +
+ {/* 메인 콘텐츠 */} +
+ {/* 좌측 패널 */} +
+ fetchItems(keyword)} + onSelectItem={selectItem} + onSelectProcess={selectProcess} + onInit={handleInit} + /> +
+ + {/* 우측 패널 */} +
+ {/* 우측 헤더 */} + {selection.routingDetailId ? ( + <> +
+
+

+ {selection.itemName} - {selection.processName} +

+
+ 품목: {selection.itemCode} + 공정: {selection.processName} + 버전: {selection.routingVersionName} +
+
+ {!config.readonly && ( + + )} +
+ + {/* 작업 단계별 섹션 */} +
+ {sortedPhases.map((phase) => ( + + ))} +
+ + ) : ( +
+ +

+ 좌측에서 품목과 공정을 선택하세요 +

+

+ 품목을 펼쳐 라우팅별 공정을 선택하면 작업기준을 관리할 수 + 있습니다 +

+
+ )} +
+
+ + {/* 작업 항목 추가/수정 모달 */} + { + setModalOpen(false); + setEditingItem(null); + }} + onSave={handleModalSave} + phaseKey={modalPhaseKey} + phaseLabel={ + config.phases.find((p) => p.key === modalPhaseKey)?.label || "" + } + detailTypes={config.detailTypes} + editItem={editingItem} + /> +
+ ); +} diff --git a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel.tsx b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel.tsx new file mode 100644 index 00000000..21a5d69f --- /dev/null +++ b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel.tsx @@ -0,0 +1,282 @@ +"use client"; + +import React from "react"; +import { Plus, Trash2, GripVertical } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { ProcessWorkStandardConfig, WorkPhaseDefinition, DetailTypeDefinition } from "./types"; +import { defaultConfig } from "./config"; + +interface ConfigPanelProps { + config: Partial; + onChange: (config: Partial) => void; +} + +export function ProcessWorkStandardConfigPanel({ + config: configProp, + onChange, +}: ConfigPanelProps) { + const config: ProcessWorkStandardConfig = { + ...defaultConfig, + ...configProp, + dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource }, + phases: configProp?.phases?.length ? configProp.phases : defaultConfig.phases, + detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes, + }; + + const update = (partial: Partial) => { + onChange({ ...configProp, ...partial }); + }; + + const updateDataSource = (field: string, value: string) => { + update({ + dataSource: { ...config.dataSource, [field]: value }, + }); + }; + + // 작업 단계 관리 + const addPhase = () => { + const nextOrder = config.phases.length + 1; + update({ + phases: [ + ...config.phases, + { key: `PHASE_${nextOrder}`, label: `단계 ${nextOrder}`, sortOrder: nextOrder }, + ], + }); + }; + + const removePhase = (idx: number) => { + update({ phases: config.phases.filter((_, i) => i !== idx) }); + }; + + const updatePhase = (idx: number, field: keyof WorkPhaseDefinition, value: string | number) => { + const next = [...config.phases]; + next[idx] = { ...next[idx], [field]: value }; + update({ phases: next }); + }; + + // 상세 유형 관리 + const addDetailType = () => { + update({ + detailTypes: [ + ...config.detailTypes, + { value: `TYPE_${config.detailTypes.length + 1}`, label: "신규 유형" }, + ], + }); + }; + + const removeDetailType = (idx: number) => { + update({ detailTypes: config.detailTypes.filter((_, i) => i !== idx) }); + }; + + const updateDetailType = (idx: number, field: keyof DetailTypeDefinition, value: string) => { + const next = [...config.detailTypes]; + next[idx] = { ...next[idx], [field]: value }; + update({ detailTypes: next }); + }; + + return ( +
+

공정 작업기준 설정

+ + {/* 데이터 소스 설정 */} +
+

데이터 소스 설정

+ +
+ + updateDataSource("itemTable", e.target.value)} + className="mt-1 h-8 text-xs" + /> +
+
+
+ + updateDataSource("itemNameColumn", e.target.value)} + className="mt-1 h-8 text-xs" + /> +
+
+ + updateDataSource("itemCodeColumn", e.target.value)} + className="mt-1 h-8 text-xs" + /> +
+
+ +
+ + updateDataSource("routingVersionTable", e.target.value)} + className="mt-1 h-8 text-xs" + /> +
+
+ + updateDataSource("routingFkColumn", e.target.value)} + className="mt-1 h-8 text-xs" + /> +
+ +
+ + updateDataSource("processTable", e.target.value)} + className="mt-1 h-8 text-xs" + /> +
+
+
+ + updateDataSource("processNameColumn", e.target.value)} + className="mt-1 h-8 text-xs" + /> +
+
+ + updateDataSource("processCodeColumn", e.target.value)} + className="mt-1 h-8 text-xs" + /> +
+
+
+ + {/* 작업 단계 설정 */} +
+
+

작업 단계 설정

+ +
+ +
+ {config.phases.map((phase, idx) => ( +
+ + updatePhase(idx, "key", e.target.value)} + className="h-7 w-20 text-[10px]" + placeholder="키" + /> + updatePhase(idx, "label", e.target.value)} + className="h-7 flex-1 text-[10px]" + placeholder="표시명" + /> + +
+ ))} +
+
+ + {/* 상세 유형 옵션 */} +
+
+

상세 유형 옵션

+ +
+ +
+ {config.detailTypes.map((dt, idx) => ( +
+ updateDetailType(idx, "value", e.target.value)} + className="h-7 w-24 text-[10px]" + placeholder="값" + /> + updateDetailType(idx, "label", e.target.value)} + className="h-7 flex-1 text-[10px]" + placeholder="표시명" + /> + +
+ ))} +
+
+ + {/* UI 설정 */} +
+

UI 설정

+ +
+ + update({ splitRatio: Number(e.target.value) })} + min={15} + max={50} + className="mt-1 h-8 w-20 text-xs" + /> +
+ +
+ + update({ leftPanelTitle: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+ +
+ update({ readonly: v })} + /> + +
+
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardRenderer.tsx b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardRenderer.tsx new file mode 100644 index 00000000..cb1e0e85 --- /dev/null +++ b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardRenderer.tsx @@ -0,0 +1,32 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2ProcessWorkStandardDefinition } from "./index"; +import { ProcessWorkStandardComponent } from "./ProcessWorkStandardComponent"; + +export class ProcessWorkStandardRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2ProcessWorkStandardDefinition; + + render(): React.ReactElement { + const { formData, isPreview, config, tableName } = this.props as Record< + string, + unknown + >; + + return ( + } + tableName={tableName as string} + isPreview={isPreview as boolean} + /> + ); + } +} + +ProcessWorkStandardRenderer.registerSelf(); + +if (process.env.NODE_ENV === "development") { + ProcessWorkStandardRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/ItemProcessSelector.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/ItemProcessSelector.tsx new file mode 100644 index 00000000..59ea4f71 --- /dev/null +++ b/frontend/lib/registry/components/v2-process-work-standard/components/ItemProcessSelector.tsx @@ -0,0 +1,167 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Search, ChevronDown, ChevronRight, Package, GitBranch, Settings2, Star } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { ItemData, RoutingVersion, SelectionState } from "../types"; + +interface ItemProcessSelectorProps { + title: string; + items: ItemData[]; + routings: RoutingVersion[]; + selection: SelectionState; + onSearch: (keyword: string) => void; + onSelectItem: (itemCode: string, itemName: string) => void; + onSelectProcess: ( + routingDetailId: string, + processName: string, + routingVersionId: string, + routingVersionName: string + ) => void; + onInit: () => void; +} + +export function ItemProcessSelector({ + title, + items, + routings, + selection, + onSearch, + onSelectItem, + onSelectProcess, + onInit, +}: ItemProcessSelectorProps) { + const [searchKeyword, setSearchKeyword] = useState(""); + const [expandedItems, setExpandedItems] = useState>(new Set()); + + useEffect(() => { + onInit(); + }, [onInit]); + + const handleSearch = (value: string) => { + setSearchKeyword(value); + onSearch(value); + }; + + const toggleItem = (itemCode: string, itemName: string) => { + const next = new Set(expandedItems); + if (next.has(itemCode)) { + next.delete(itemCode); + } else { + next.add(itemCode); + onSelectItem(itemCode, itemName); + } + setExpandedItems(next); + }; + + const isItemExpanded = (itemCode: string) => expandedItems.has(itemCode); + + return ( +
+ {/* 헤더 */} +
+
+ + {title} +
+
+ + handleSearch(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+
+ + {/* 트리 목록 */} +
+ {items.length === 0 ? ( +
+ +

+ 라우팅이 등록된 품목이 없습니다 +

+
+ ) : ( + items.map((item) => ( +
+ {/* 품목 헤더 */} + + + {/* 라우팅 + 공정 */} + {isItemExpanded(item.item_code) && + selection.itemCode === item.item_code && ( +
+ {routings.length === 0 ? ( +

+ 등록된 공정이 없습니다 +

+ ) : ( + routings.map((routing) => ( +
+ {/* 라우팅 버전 */} +
+ + + {routing.version_name || "기본 라우팅"} + +
+ + {/* 공정 목록 */} + {routing.processes.map((proc) => ( + + ))} +
+ )) + )} +
+ )} +
+ )) + )} +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemAddModal.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemAddModal.tsx new file mode 100644 index 00000000..6a907f58 --- /dev/null +++ b/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemAddModal.tsx @@ -0,0 +1,337 @@ +"use client"; + +import React, { useState } from "react"; +import { Plus, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { DetailTypeDefinition, WorkItem } from "../types"; + +interface ModalDetail { + id: string; + detail_type: string; + content: string; + is_required: string; + sort_order: number; +} + +interface WorkItemAddModalProps { + open: boolean; + onClose: () => void; + onSave: (data: { + work_phase: string; + title: string; + is_required: string; + description?: string; + details?: Array<{ + detail_type?: string; + content: string; + is_required: string; + sort_order: number; + }>; + }) => void; + phaseKey: string; + phaseLabel: string; + detailTypes: DetailTypeDefinition[]; + editItem?: WorkItem | null; +} + +export function WorkItemAddModal({ + open, + onClose, + onSave, + phaseKey, + phaseLabel, + detailTypes, + editItem, +}: WorkItemAddModalProps) { + const [title, setTitle] = useState(editItem?.title || ""); + const [isRequired, setIsRequired] = useState(editItem?.is_required || "Y"); + const [description, setDescription] = useState(editItem?.description || ""); + const [details, setDetails] = useState([]); + + const resetForm = () => { + setTitle(""); + setIsRequired("Y"); + setDescription(""); + setDetails([]); + }; + + const handleSave = () => { + if (!title.trim()) return; + onSave({ + work_phase: phaseKey, + title: title.trim(), + is_required: isRequired, + description: description.trim() || undefined, + details: details + .filter((d) => d.content.trim()) + .map((d, idx) => ({ + detail_type: d.detail_type || undefined, + content: d.content.trim(), + is_required: d.is_required, + sort_order: idx + 1, + })), + }); + resetForm(); + onClose(); + }; + + const addDetail = () => { + setDetails((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + detail_type: detailTypes[0]?.value || "", + content: "", + is_required: "N", + sort_order: prev.length + 1, + }, + ]); + }; + + const removeDetail = (id: string) => { + setDetails((prev) => prev.filter((d) => d.id !== id)); + }; + + const updateDetailField = ( + id: string, + field: keyof ModalDetail, + value: string | number + ) => { + setDetails((prev) => + prev.map((d) => (d.id === id ? { ...d, [field]: value } : d)) + ); + }; + + return ( + { + if (!v) { + resetForm(); + onClose(); + } + }} + > + + + + 작업 항목 {editItem ? "수정" : "추가"} + + + {phaseLabel} 단계에 {editItem ? "항목을 수정" : "새 항목을 추가"}합니다. + + + +
+ {/* 기본 정보 */} +
+

+ 기본 정보 +

+
+
+ + setTitle(e.target.value)} + placeholder="예: 장비 점검, 품질 검사" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+
+ +