diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 7a87d1a0..84a8729c 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -3,6 +3,10 @@ "agent-orchestrator": { "command": "node", "args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"] + }, + "Framelink Figma MCP": { + "command": "npx", + "args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"] } } } diff --git a/.gitignore b/.gitignore index e9b5b7fb..a4c207df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Claude Code (로컬 전용 - Git 제외) +.claude/ + # Dependencies node_modules/ npm-debug.log* @@ -291,4 +294,8 @@ claude.md # AI 에이전트 테스트 산출물 *-test-screenshots/ *-screenshots/ -*-test.mjs \ No newline at end of file +*-test.mjs + +# 개인 작업 문서 (popdocs) +popdocs/ +.cursor/rules/popdocs-safety.mdc \ No newline at end of file diff --git a/PLAN.MD b/PLAN.MD index 0eff7965..49d2d7e4 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,139 +1,337 @@ -# 프로젝트: V2/V2 컴포넌트 설정 스키마 정비 +# 현재 구현 계획: pop-card-list 입력 필드/계산 필드 구조 개편 -## 개요 - -레거시 컴포넌트를 제거하고, V2/V2 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 한 곳에서 관리한다. - -## 핵심 기능 - -1. [x] 레거시 컴포넌트 스키마 제거 -2. [x] V2 컴포넌트 overrides 스키마 정의 (16개) -3. [x] V2 컴포넌트 overrides 스키마 정의 (9개) -4. [x] componentConfig.ts 한 파일에서 통합 관리 - -## 정의된 V2 컴포넌트 (18개) - -- v2-table-list, v2-button-primary, v2-text-display -- v2-split-panel-layout, v2-section-card, v2-section-paper -- v2-divider-line, v2-repeat-container, v2-rack-structure -- v2-numbering-rule, v2-category-manager, v2-pivot-grid -- v2-location-swap-selector, v2-aggregation-widget -- v2-card-display, v2-table-search-widget, v2-tabs-widget -- v2-v2-repeater - -## 정의된 V2 컴포넌트 (9개) - -- v2-input, v2-select, v2-date -- v2-list, v2-layout, v2-group -- v2-media, v2-biz, v2-hierarchy - -## 테스트 계획 - -### 1단계: 기본 기능 - -- [x] V2 레이아웃 저장 시 컴포넌트별 overrides 스키마 검증 통과 -- [x] V2 컴포넌트 기본값과 스키마가 매칭됨 - -### 2단계: 에러 케이스 - -- [x] 잘못된 overrides 입력 시 Zod 검증 실패 처리 (safeParse + console.warn + graceful fallback) -- [x] 누락된 기본값 컴포넌트 저장 시 안전한 기본값 적용 (레지스트리 조회 → 빈 객체) - -## 에러 처리 계획 - -- 스키마 파싱 실패 시 로그/에러 메시지 표준화 -- 기본값 누락 시 안전한 fallback 적용 - -## 진행 상태 - -- [x] 레거시 컴포넌트 제거 완료 -- [x] V2/V2 스키마 정의 완료 -- [x] 한 파일 통합 관리 완료 - -# 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후) - -## 개요 - -채번/카테고리에서 `menu_objid` 의존성 제거 완료 후, 화면 복제 기능을 새 DB 구조에 맞게 수정하고 테스트합니다. - -## 핵심 변경사항 - -### DB 구조 변경 (완료) - -- 채번규칙: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반 -- 카테고리: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반 -- 복제 순서 의존성 문제 해결 - -### 복제 옵션 정리 (완료) - -- [x] **삭제**: 코드 카테고리 + 코드 복사 옵션 -- [x] **삭제**: 연쇄관계 설정 복사 옵션 -- [x] **이름 변경**: "카테고리 매핑 + 값 복사" → "카테고리 값 복사" - -### 현재 복제 옵션 (3개) - -1. **채번 규칙 복사** - 채번규칙 복제 -2. **카테고리 값 복사** - 카테고리 값 복제 (table_column_category_values) -3. **테이블 타입관리 입력타입 설정 복사** - table_type_columns 복제 +> **작성일**: 2026-02-24 +> **상태**: 계획 완료, 코딩 대기 +> **목적**: 입력 필드 설정 단순화 + 본문 필드에 계산식 통합 + 기존 계산 필드 섹션 제거 --- -## 테스트 계획 +## 1. 변경 개요 -### 1. 화면 간 연결 복제 테스트 +### 배경 +- 기존: "입력 필드", "계산 필드", "담기 버튼" 3개가 별도 섹션으로 분리 +- 문제: 계산 필드가 본문 필드와 동일한 위치에 표시되어야 하는데 별도 영역에 있음 +- 문제: 입력 필드의 min/max 고정값은 비실용적 (실제로는 DB 컬럼 기준 제한이 필요) +- 문제: step, columnName, sourceColumns, resultColumn 등 죽은 코드 존재 -- [ ] 수주관리 1번→2번→3번→4번 화면 연결 상태에서 복제 -- [ ] 복제 후 연결 관계가 유지되는지 확인 -- [ ] 각 화면의 고유 키값이 새로운 화면을 참조하도록 변경되는지 확인 - -### 2. 제어관리 복제 테스트 - -- [ ] 다른 회사로 제어관리 복제 -- [ ] 복제된 플로우 스텝/연결이 정상 작동하는지 확인 - -### 3. 추가 옵션 복제 테스트 - -- [ ] 채번규칙 복사 정상 작동 확인 -- [ ] 카테고리 값 복사 정상 작동 확인 -- [ ] 테이블 타입관리 입력타입 설정 복사 정상 작동 확인 - -### 4. 기본 복제 테스트 - -- [ ] 단일 화면 복제 (모달 포함) -- [ ] 그룹 전체 복제 (재귀적) -- [ ] 메뉴 동기화 정상 작동 +### 목표 +1. **본문 필드에 계산식 지원 추가** - 필드별로 "DB 컬럼" 또는 "계산식" 선택 +2. **입력 필드 설정 단순화** - 고정 min/max 제거, 제한 기준 컬럼 방식으로 변경 +3. **기존 "계산 필드" 섹션 제거** - 본문 필드에 통합되므로 불필요 +4. **죽은 코드 정리** --- -## 관련 파일 +## 2. 수정 대상 파일 (3개) -- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달 -- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴 -- `backend-node/src/services/screenManagementService.ts` - 복제 서비스 -- `backend-node/src/services/numberingRuleService.ts` - 채번규칙 서비스 -- `docs/DB_STRUCTURE_DIAGRAM.md` - DB 구조 문서 +### 파일 A: `frontend/lib/registry/pop-components/types.ts` -## 진행 상태 +#### 변경 A-1: CardFieldBinding 타입 확장 -- [완료] DB 구조 개편 (menu_objid 의존성 제거) -- [완료] 복제 옵션 정리 (코드카테고리/연쇄관계 삭제, 이름 변경) -- [완료] 화면 간 연결 복제 버그 수정 (targetScreenId 매핑 추가) -- [대기] 화면 간 연결 복제 테스트 -- [대기] 제어관리 복제 테스트 -- [대기] 추가 옵션 복제 테스트 +**현재 코드** (라인 367~372): +```typescript +export interface CardFieldBinding { + id: string; + columnName: string; + label: string; + textColor?: string; +} +``` + +**변경 코드**: +```typescript +export interface CardFieldBinding { + id: string; + label: string; + textColor?: string; + valueType: "column" | "formula"; // 값 유형: DB 컬럼 또는 계산식 + columnName?: string; // valueType === "column"일 때 사용 + formula?: string; // valueType === "formula"일 때 사용 (예: "$input - received_qty") + unit?: string; // 계산식일 때 단위 표시 (예: "EA") +} +``` + +**주의**: `columnName`이 required에서 optional로 변경됨. 기존 저장 데이터와의 하위 호환 필요. + +#### 변경 A-2: CardInputFieldConfig 단순화 + +**현재 코드** (라인 443~453): +```typescript +export interface CardInputFieldConfig { + enabled: boolean; + columnName?: string; + label?: string; + unit?: string; + defaultValue?: number; + min?: number; + max?: number; + maxColumn?: string; + step?: number; +} +``` + +**변경 코드**: +```typescript +export interface CardInputFieldConfig { + enabled: boolean; + label?: string; + unit?: string; + limitColumn?: string; // 제한 기준 컬럼 (해당 행의 이 컬럼 값이 최대값) + saveTable?: string; // 저장 대상 테이블 + saveColumn?: string; // 저장 대상 컬럼 + showPackageUnit?: boolean; // 포장등록 버튼 표시 여부 +} +``` + +**제거 항목**: +- `columnName` -> `saveTable` + `saveColumn`으로 대체 (명확한 네이밍) +- `defaultValue` -> 제거 (제한 기준 컬럼 값으로 대체) +- `min` -> 제거 (항상 0) +- `max` -> 제거 (`limitColumn`으로 대체) +- `maxColumn` -> `limitColumn`으로 이름 변경 +- `step` -> 제거 (키패드 방식에서 미사용) + +#### 변경 A-3: CardCalculatedFieldConfig 제거 + +**삭제**: `CardCalculatedFieldConfig` 인터페이스 전체 (라인 457~464) +**삭제**: `PopCardListConfig`에서 `calculatedField?: CardCalculatedFieldConfig;` 제거 --- -## 수정 이력 +### 파일 B: `frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx` -### 2026-01-26: 버튼 targetScreenId 매핑 버그 수정 +#### 변경 B-1: 본문 필드 편집기(FieldEditor)에 값 유형 선택 추가 -**문제**: 그룹 복제 시 버튼의 `targetScreenId`가 새 화면으로 매핑되지 않음 +**현재**: 필드 편집 시 라벨, 컬럼, 텍스트색상만 설정 가능 -- 수주관리 1→2→3→4 화면 복제 시 연결이 깨지는 문제 +**변경**: 값 유형 라디오("DB 컬럼" / "계산식") 추가 +- "DB 컬럼" 선택 시: 기존 컬럼 Select 표시 +- "계산식" 선택 시: 수식 입력란 + 사용 가능한 컬럼/변수 칩 목록 표시 +- 사용 가능한 변수: DB 컬럼명들 + `$input` (입력 필드 활성화 시) -**수정 파일**: `backend-node/src/services/screenManagementService.ts` +**하위 호환**: 기존 저장 데이터에 `valueType`이 없으면 `"column"`으로 기본 처리 -- `updateTabScreenReferences` 함수에 `targetScreenId` 처리 로직 추가 -- 쿼리에 `targetScreenId` 검색 조건 추가 -- 문자열/숫자 타입 모두 처리 +#### 변경 B-2: 입력 필드 설정 섹션 개편 + +**현재 설정 항목**: 라벨, 단위, 기본값, 최소/최대, 최대값 컬럼, 저장 컬럼 + +**변경 설정 항목**: +``` +라벨 [입고 수량 ] +단위 [EA ] +제한 기준 컬럼 [ order_qty v ] +저장 대상 테이블 [ 선택 v ] +저장 대상 컬럼 [ 선택 v ] +─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ +포장등록 버튼 [on/off] +``` + +#### 변경 B-3: "계산 필드" 섹션 제거 + +**삭제**: `CalculatedFieldSettingsSection` 함수 전체 +**삭제**: 카드 템플릿 탭에서 "계산 필드" CollapsibleSection 제거 + +#### 변경 B-4: import 정리 + +**삭제**: `CardCalculatedFieldConfig` import +**추가**: 없음 (기존 import 재사용) + +--- + +### 파일 C: `frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx` + +#### 변경 C-1: FieldRow에서 계산식 필드 지원 + +**현재**: `const value = row[field.columnName]` 로 DB 값만 표시 + +**변경**: +```typescript +function FieldRow({ field, row, scaled, inputValue }: { + field: CardFieldBinding; + row: RowData; + scaled: ScaledConfig; + inputValue?: number; // 입력 필드 값 (계산식에서 $input으로 참조) +}) { + const value = field.valueType === "formula" && field.formula + ? evaluateFormula(field.formula, row, inputValue ?? 0) + : row[field.columnName ?? ""]; + // ... +} +``` + +**주의**: `inputValue`를 FieldRow까지 전달해야 하므로 CardItem -> FieldRow 경로에 prop 추가 필요 + +#### 변경 C-2: 계산식 필드 실시간 갱신 + +**현재**: 별도 `calculatedValue` useMemo가 `[calculatedField, row, inputValue]`에 반응 + +**변경**: FieldRow가 `inputValue` prop을 받으므로, `inputValue`가 변경될 때 계산식 필드가 자동으로 리렌더링됨. 별도 useMemo 불필요. + +#### 변경 C-3: 기존 calculatedField 관련 코드 제거 + +**삭제 대상**: +- `calculatedField` prop 전달 (CardItem) +- `calculatedValue` useMemo +- 계산 필드 렌더링 블록 (`{calculatedField?.enabled && calculatedValue !== null && (...)}` + +#### 변경 C-4: 입력 필드 로직 단순화 + +**변경 대상**: +- `effectiveMax`: `limitColumn` 사용, 미설정 시 999999 폴백 +- `defaultValue` 자동 초기화 로직 제거 (불필요) +- `NumberInputModal`에 포장등록 on/off 전달 + +#### 변경 C-5: NumberInputModal에 포장등록 on/off 전달 + +**현재**: 포장등록 버튼 항상 표시 +**변경**: `showPackageUnit` prop 추가, false이면 포장등록 버튼 숨김 + +--- + +### 파일 D: `frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx` + +#### 변경 D-1: showPackageUnit prop 추가 + +**현재 props**: open, onOpenChange, unit, initialValue, initialPackageUnit, min, maxValue, onConfirm + +**추가 prop**: `showPackageUnit?: boolean` (기본값 true) + +**변경**: `showPackageUnit === false`이면 포장등록 버튼 숨김 + +--- + +## 3. 구현 순서 (의존성 기반) + +| 순서 | 작업 | 파일 | 의존성 | 상태 | +|------|------|------|--------|------| +| 1 | A-1: CardFieldBinding 타입 확장 | types.ts | 없음 | [ ] | +| 2 | A-2: CardInputFieldConfig 단순화 | types.ts | 없음 | [ ] | +| 3 | A-3: CardCalculatedFieldConfig 제거 | types.ts | 없음 | [ ] | +| 4 | B-1: FieldEditor에 값 유형 선택 추가 | PopCardListConfig.tsx | 순서 1 | [ ] | +| 5 | B-2: 입력 필드 설정 섹션 개편 | PopCardListConfig.tsx | 순서 2 | [ ] | +| 6 | B-3: 계산 필드 섹션 제거 | PopCardListConfig.tsx | 순서 3 | [ ] | +| 7 | B-4: import 정리 | PopCardListConfig.tsx | 순서 6 | [ ] | +| 8 | D-1: NumberInputModal showPackageUnit 추가 | NumberInputModal.tsx | 없음 | [ ] | +| 9 | C-1: FieldRow 계산식 지원 | PopCardListComponent.tsx | 순서 1 | [ ] | +| 10 | C-3: calculatedField 관련 코드 제거 | PopCardListComponent.tsx | 순서 9 | [ ] | +| 11 | C-4: 입력 필드 로직 단순화 | PopCardListComponent.tsx | 순서 2, 8 | [ ] | +| 12 | 린트 검사 | 전체 | 순서 1~11 | [ ] | + +순서 1, 2, 3은 독립이므로 병렬 가능. +순서 8은 독립이므로 병렬 가능. + +--- + +## 4. 사전 충돌 검사 결과 + +### 새로 추가할 식별자 목록 + +| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 | +|--------|------|-----------|-----------|-----------| +| `valueType` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 | +| `formula` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 (기존 CardCalculatedFieldConfig.formula와 다른 인터페이스) | +| `limitColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 | +| `saveTable` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 | +| `saveColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 | +| `showPackageUnit` | CardInputFieldConfig 속성 / NumberInputModal prop | types.ts, NumberInputModal.tsx | PopCardListComponent.tsx | 충돌 없음 | + +### 기존 타입/함수 재사용 목록 + +| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 | +|------------|-----------|------------------------| +| `evaluateFormula()` | PopCardListComponent.tsx 라인 1026 | C-1: FieldRow에서 호출 (기존 함수 그대로 재사용) | +| `CardFieldBinding` | types.ts 라인 367 | A-1에서 수정, B-1/C-1에서 사용 | +| `CardInputFieldConfig` | types.ts 라인 443 | A-2에서 수정, B-2/C-4에서 사용 | +| `GroupedColumnSelect` | PopCardListConfig.tsx | B-1: 계산식 모드에서 컬럼 칩 표시에 재사용 가능 | + +**사용처 있는데 정의 누락된 항목: 없음** + +--- + +## 5. 에러 함정 경고 + +### 함정 1: 기존 저장 데이터 하위 호환 +기존에 저장된 `CardFieldBinding`에는 `valueType`이 없고 `columnName`이 필수였음. +**반드시** 런타임에서 `field.valueType || "column"` 폴백 처리해야 함. +Config UI에서도 `valueType` 미존재 시 `"column"` 기본값 적용 필요. + +### 함정 2: CardInputFieldConfig 하위 호환 +기존 `maxColumn`이 `limitColumn`으로 이름 변경됨. +기존 저장 데이터의 `maxColumn`을 `limitColumn`으로 읽어야 함. +런타임: `inputField?.limitColumn || (inputField as any)?.maxColumn` 폴백 필요. + +### 함정 3: evaluateFormula의 inputValue 전달 +FieldRow에 `inputValue`를 전달하려면, CardItem -> body.fields.map -> FieldRow 경로에서 `inputValue` prop을 추가해야 함. +입력 필드가 비활성화된 경우 `inputValue`는 0으로 전달. + +### 함정 4: calculatedField 제거 시 기존 데이터 +기존 config에 `calculatedField` 데이터가 남아 있을 수 있음. +타입에서 제거하더라도 런타임 에러는 나지 않음 (unknown 속성은 무시됨). +다만 이전에 계산 필드로 설정한 내용은 사라짐 - 마이그레이션 없이 제거. + +### 함정 5: columnName optional 변경 +`CardFieldBinding.columnName`이 optional이 됨. +기존에 `row[field.columnName]`으로 직접 접근하던 코드 전부 수정 필요. +`field.columnName ?? ""` 또는 valueType 분기 처리. + +--- + +## 6. 검증 방법 + +### 시나리오 1: 기존 본문 필드 (하위 호환) +1. 기존 저장된 카드리스트 열기 +2. 본문 필드에 기존 DB 컬럼 필드가 정상 표시되는지 확인 +3. 설정 패널에서 기존 필드가 "DB 컬럼" 유형으로 표시되는지 확인 + +### 시나리오 2: 계산식 본문 필드 추가 +1. 본문 필드 추가 -> 값 유형 "계산식" 선택 +2. 수식: `order_qty - received_qty` 입력 +3. 카드에서 계산 결과가 정상 표시되는지 확인 + +### 시나리오 3: $input 참조 계산식 +1. 입력 필드 활성화 +2. 본문 필드 추가 -> 값 유형 "계산식" -> 수식: `$input - received_qty` +3. 키패드에서 수량 입력 시 계산 결과가 실시간 갱신되는지 확인 + +### 시나리오 4: 제한 기준 컬럼 +1. 입력 필드 -> 제한 기준 컬럼: `order_qty` +2. order_qty=1000인 카드에서 키패드 열기 +3. MAX 버튼 클릭 시 1000이 입력되고, 1001 이상 입력 불가 확인 + +### 시나리오 5: 포장등록 on/off +1. 입력 필드 -> 포장등록 버튼: off +2. 키패드 모달에서 포장등록 버튼이 숨겨지는지 확인 + +--- + +## 이전 완료 계획 (아카이브) + +
+pop-dashboard 4가지 아이템 모드 완성 (완료) + +- [x] groupBy UI 추가 +- [x] xAxisColumn 입력 UI 추가 +- [x] 통계카드 카테고리 설정 UI 추가 +- [x] 차트 xAxisColumn 자동 보정 로직 +- [x] 통계카드 카테고리별 필터 적용 +- [x] SQL 빌더 방어 로직 +- [x] refreshInterval 최소값 강제 + +
+ +
+POP 뷰어 스크롤 수정 (완료) + +- [x] overflow-hidden 제거 +- [x] overflow-auto 공통 적용 +- [x] 일반 모드 min-h-full 추가 + +
+ +
+POP 뷰어 실제 컴포넌트 렌더링 (완료) + +- [x] 뷰어 페이지에 레지스트리 초기화 import 추가 +- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체 + +
diff --git a/POPUPDATE_2.md b/POPUPDATE_2.md new file mode 100644 index 00000000..85e20af2 --- /dev/null +++ b/POPUPDATE_2.md @@ -0,0 +1,696 @@ +# POP 컴포넌트 정의서 v8.0 + +## POP 헌법 (공통 규칙) + +### 제1조. 컴포넌트의 정의 + +- 컴포넌트란 디자이너가 그리드에 배치하는 것이다 +- 그리드에 배치하지 않는 것은 컴포넌트가 아니다 + +### 제2조. 컴포넌트의 독립성 + +- 모든 컴포넌트는 독립적으로 동작한다 +- 컴포넌트는 다른 컴포넌트의 존재를 직접 알지 못한다 (이벤트 버스로만 통신) + +### 제3조. 데이터의 자유 + +- 모든 컴포넌트는 자신의 테이블 + 외부 테이블을 자유롭게 조인할 수 있다 +- 컬럼별로 read/write/readwrite/hidden을 개별 설정할 수 있다 +- 보유 데이터 중 원하는 컬럼만 골라서 저장할 수 있다 + +### 제4조. 통신의 규칙 + +- 컴포넌트 간 통신은 반드시 이벤트 버스(usePopEvent)를 통한다 +- 컴포넌트가 다른 컴포넌트를 직접 참조하거나 호출하지 않는다 +- 이벤트는 화면 단위로 격리된다 (다른 POP 화면의 이벤트를 받지 않는다) +- 같은 화면 안에서는 이벤트를 통해 자유롭게 데이터를 주고받을 수 있다 + +### 제5조. 역할의 분리 + +- 조회용 입력(pop-search)과 저장용 입력(pop-field)은 다른 컴포넌트다 +- 이동/실행(pop-icon)과 값 선택 후 반환(pop-lookup)은 다른 컴포넌트다 +- 자주 쓰는 패턴은 하나로 합치되, 흐름 자체는 강제하고 보이는 방식만 옵션으로 제공한다 + +### 제6조. 시스템 설정도 컴포넌트다 + +- 프로필, 테마, 대시보드 보이기/숨기기 같은 시스템 설정도 컴포넌트(pop-system)로 만든다 +- 디자이너가 pop-system을 배치하지 않으면 해당 화면에 설정 기능이 없다 +- 이를 통해 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정한다 + +### 제7조. 디자이너의 권한 + +- 디자이너는 컴포넌트를 배치하고 설정한다 +- 디자이너는 사용자에게 커스텀을 허용할지 말지 결정한다 (userConfigurable) +- 디자이너가 "사용자 커스텀 허용 = OFF"로 설정하면, 사용자는 변경할 수 없다 +- 컴포넌트의 옵션 설정(어떻게 저장하고 어떻게 조회하는지 등)은 디자이너가 결정한다 + +### 제8조. 컴포넌트의 구성 + +- 모든 컴포넌트는 3개 파일로 구성된다: 실제 컴포넌트, 디자인 미리보기, 설정 패널 +- 모든 컴포넌트는 레지스트리에 등록해야 디자이너에 나타난다 +- 모든 컴포넌트 인스턴스는 userConfigurable, displayName 공통 속성을 가진다 + +### 제9조. 모달 화면의 설계 + +- 모달은 인라인(컴포넌트 설정만으로 구성)과 외부 참조(별도 POP 화면 연결) 두 가지 방식이 있다 +- 단순한 목록 선택은 인라인 모달을 사용한다 (설정만으로 완결) +- 복잡한 검색/필터가 필요하거나 여러 곳에서 재사용하는 모달은 별도 POP 화면을 만들어 참조한다 +- 모달 안의 화면도 동일한 POP 컴포넌트 시스템으로 구성된다 (같은 그리드, 같은 컴포넌트) +- 모달 화면의 layout_data는 기존 screen_layouts_pop 테이블에 저장한다 (DB 변경 불필요) + +--- + +## 현재 상태 + +- 그리드 시스템 (v5.2): 완성 +- 컴포넌트 레지스트리: 완성 (PopComponentRegistry.ts) +- 구현 완료: `pop-text` 1개 (pop-text.tsx) +- 기존 `components-spec.md`는 v4 기준이라 갱신 필요 + +## 아키텍처 개요 + +```mermaid +graph TB + subgraph designer [디자이너] + Palette[컴포넌트 팔레트] + Grid[CSS Grid 캔버스] + ConfigPanel[속성 설정 패널] + end + + subgraph registry [레지스트리] + Registry[PopComponentRegistry] + end + + subgraph infra [공통 인프라] + DataSource[useDataSource 훅] + EventBus[usePopEvent 훅] + ActionRunner[usePopAction 훅] + end + + subgraph components [9개 컴포넌트] + Text[pop-text - 완성] + Dashboard[pop-dashboard] + Table[pop-table] + Button[pop-button] + Icon[pop-icon] + Search[pop-search] + Field[pop-field] + Lookup[pop-lookup] + System[pop-system] + end + + subgraph backend [기존 백엔드 API] + DataAPI[dataApi - 동적 CRUD] + DashAPI[dashboardApi - 통계 쿼리] + CodeAPI[commonCodeApi - 공통코드] + NumberAPI[numberingRuleApi - 채번] + end + + Palette --> Grid + Grid --> ConfigPanel + ConfigPanel --> Registry + + Registry --> components + components --> infra + infra --> backend + EventBus -.->|컴포넌트 간 통신| components + System -.->|보이기/숨기기 제어| components +``` + +--- + +## 공통 인프라 (모든 컴포넌트가 공유) + +### 핵심 원칙: 모든 컴포넌트는 데이터를 자유롭게 다룬다 + +1. **데이터 전달**: 모든 컴포넌트는 자신이 보유한 데이터를 다른 컴포넌트에 전달/수신 가능 +2. **테이블 조인**: 자신의 테이블 + 외부 테이블 자유롭게 조인하여 데이터 구성 +3. **컬럼별 CRUD 제어**: 컬럼 단위로 "조회만" / "저장 대상" / "숨김"을 개별 설정 가능 +4. **선택적 저장**: 보유 데이터 중 원하는 컬럼만 골라서 저장/수정/삭제 가능 + +### 공통 인스턴스 속성 (모든 컴포넌트 배치 시 설정 가능) + +디자이너가 컴포넌트를 그리드에 배치할 때 설정하는 공통 속성: + +- `userConfigurable`: boolean - 사용자가 이 컴포넌트를 숨길 수 있는지 (개인 설정 패널에 노출) +- `displayName`: string - 개인 설정 패널에 보여줄 이름 (예: "금일 생산실적") + +### 1. DataSourceConfig (데이터 소스 설정 타입) + +모든 데이터 연동 컴포넌트가 사용하는 표준 설정 구조: + +- `tableName`: 대상 테이블 +- `columns`: 컬럼 바인딩 목록 (ColumnBinding 배열) +- `filters`: 필터 조건 배열 +- `sort`: 정렬 설정 +- `aggregation`: 집계 함수 (count, sum, avg, min, max) +- `joins`: 테이블 조인 설정 (JoinConfig 배열) +- `refreshInterval`: 자동 새로고침 주기 (초) +- `limit`: 조회 건수 제한 + +### 1-1. ColumnBinding (컬럼별 읽기/쓰기 제어) + +각 컬럼이 컴포넌트에서 어떤 역할을 하는지 개별 설정: + +- `columnName`: 컬럼명 +- `sourceTable`: 소속 테이블 (조인된 외부 테이블 포함) +- `mode`: "read" | "write" | "readwrite" | "hidden" + - read: 조회만 (화면에 표시하되 저장 안 함) + - write: 저장 대상 (사용자 입력 -> DB 저장) + - readwrite: 조회 + 저장 모두 + - hidden: 내부 참조용 (화면에 안 보이지만 다른 컴포넌트에 전달 가능) +- `label`: 화면 표시 라벨 +- `defaultValue`: 기본값 + +예시: 발주 품목 카드에서 5개 컬럼 중 3개만 저장 + +``` +columns: [ + { columnName: "item_code", sourceTable: "order_items", mode: "read" }, + { columnName: "item_name", sourceTable: "item_info", mode: "read" }, + { columnName: "inbound_qty", sourceTable: "order_items", mode: "readwrite" }, + { columnName: "warehouse", sourceTable: "order_items", mode: "write" }, + { columnName: "memo", sourceTable: "order_items", mode: "write" }, +] +``` + +### 1-2. JoinConfig (테이블 조인 설정) + +외부 테이블과 자유롭게 조인: + +- `targetTable`: 조인할 외부 테이블명 +- `joinType`: "inner" | "left" | "right" +- `on`: 조인 조건 { sourceColumn, targetColumn } +- `columns`: 가져올 컬럼 목록 + +### 2. useDataSource 훅 + +DataSourceConfig를 받아서 기존 API를 호출하고 결과를 반환: + +- 로딩/에러/데이터 상태 관리 +- 자동 새로고침 타이머 +- 필터 변경 시 자동 재조회 +- 기존 `dataApi`, `dashboardApi` 활용 +- **CRUD 함수 제공**: save(data), update(id, data), delete(id) + - ColumnBinding의 mode가 "write" 또는 "readwrite"인 컬럼만 저장 대상에 포함 + - "read" 컬럼은 저장 시 자동 제외 + +### 3. usePopEvent 훅 (이벤트 버스 - 데이터 전달 포함) + +컴포넌트 간 통신 (단순 이벤트 + 데이터 페이로드): + +- `publish(eventName, payload)`: 이벤트 발행 +- `subscribe(eventName, callback)`: 이벤트 구독 +- `getSharedData(key)`: 공유 데이터 직접 읽기 +- `setSharedData(key, value)`: 공유 데이터 직접 쓰기 +- 화면 단위 스코프 (다른 POP 화면과 격리) + +### 4. PopActionConfig (액션 설정 타입) + +모든 컴포넌트가 사용할 수 있는 액션 표준 구조: + +- `type`: "navigate" | "modal" | "save" | "delete" | "api" | "event" | "refresh" +- `navigate`: { screenId, url } +- `modal`: { mode, title, screenId, inlineConfig, modalSize } + - mode: "inline" (설정만으로 구성) | "screen-ref" (별도 화면 참조) + - title: 모달 제목 + - screenId: mode가 "screen-ref"일 때 참조할 POP 화면 ID + - inlineConfig: mode가 "inline"일 때 사용할 DataSourceConfig + 표시 설정 + - modalSize: { width, height } 모달 크기 +- `save`: { targetColumns } +- `delete`: { confirmMessage } +- `api`: { method, endpoint, body } +- `event`: { eventName, payload } +- `refresh`: { targetComponents } + +--- + +## 컴포넌트 정의 (9개) + +### 1. pop-text (완성) + +- **한 줄 정의**: 보여주기만 함 +- **카테고리**: display +- **역할**: 정적 표시 전용 (이벤트 없음) +- **서브타입**: text, datetime, image, title +- **데이터**: 없음 (정적 콘텐츠) +- **이벤트**: 발행 없음, 수신 없음 +- **설정**: 내용, 폰트 크기/굵기, 좌우/상하 정렬, 이미지 URL/맞춤/크기, 날짜 포맷 빌더 + +### 2. pop-dashboard (신규 - 2026-02-09 토의 결과 반영) + +- **한 줄 정의**: 여러 집계 아이템을 묶어서 다양한 방식으로 보여줌 +- **카테고리**: display +- **역할**: 숫자 데이터를 집계/계산하여 시각화. 하나의 컴포넌트 안에 여러 집계 아이템을 담는 컨테이너 +- **구조**: 1개 pop-dashboard = 여러 DashboardItem의 묶음. 각 아이템은 독립적으로 데이터 소스/서브타입/보이기숨기기 설정 가능 +- **서브타입** (아이템별로 선택, 한 묶음에 혼합 가능): + - kpi-card: 숫자 + 단위 + 라벨 + 증감 표시 + - chart: 막대/원형/라인 차트 + - gauge: 게이지 (목표 대비 달성률) + - stat-card: 통계 카드 (건수 + 대기 + 링크) +- **표시 모드** (디자이너가 선택): + - arrows: 좌우 버튼으로 아이템 넘기기 + - auto-slide: 전광판처럼 자동 전환 (터치 시 멈춤, 일정 시간 후 재개) + - grid: 컴포넌트 영역 내부를 행/열로 쪼개서 여러 아이템 동시 표시 (디자이너가 각 아이템 위치 직접 지정) + - scroll: 좌우 또는 상하 스와이프 +- **데이터**: 각 아이템별 독립 DataSourceConfig (조인/집계 자유) +- **계산식 지원**: "생산량/총재고량", "출고량/현재고량" 같은 복합 표현 가능 + - 값 A, B를 각각 다른 테이블/집계로 설정 + - 표시 형태: 분수(1,234/5,678), 퍼센트(21.7%), 비율(1,234:5,678) +- **CRUD**: 주로 읽기. 목표값 수정 등 필요 시 write 컬럼으로 저장 가능 +- **이벤트**: + - 수신: filter_changed, data_ready + - 발행: kpi_clicked (아이템 클릭 시 상세 데이터 전달) +- **설정**: 데이터 소스(드롭다운 기반 쉬운 집계), 집계 함수, 계산식, 라벨, 단위, 색상 구간, 차트 타입, 새로고침 주기, 목표값, 표시 모드, 아이템별 보이기/숨기기 +- **보이기/숨기기**: 각 아이템별로 pop-system에서 개별 on/off 가능 (userConfigurable) +- **기존 POP 대시보드 폐기**: `frontend/components/pop/dashboard/` 폴더 전체를 이 컴포넌트로 대체 예정 (Phase 1~3 완료 후) + +#### pop-dashboard 데이터 구조 + +``` +PopDashboardConfig { + items: DashboardItem[] // 아이템 목록 (각각 독립 설정) + displayMode: "arrows" | "auto-slide" | "grid" | "scroll" + autoSlideInterval: number // 자동 슬라이드 간격(초) + gridLayout: { columns: number, rows: number } // 행열 그리드 설정 + showIndicator: boolean // 페이지 인디케이터 표시 + gap: number // 아이템 간 간격 +} + +DashboardItem { + id: string + label: string // pop-system에서 보이기/숨기기용 이름 + visible: boolean // 보이기/숨기기 + subType: "kpi-card" | "chart" | "gauge" | "stat-card" + dataSource: DataSourceConfig // 각 아이템별 독립 데이터 소스 + + // 행열 그리드 모드에서의 위치 (디자이너가 직접 지정) + gridPosition: { col: number, row: number, colSpan: number, rowSpan: number } + + // 계산식 (선택사항) + formula?: { + enabled: boolean + values: [ + { id: "A", dataSource: DataSourceConfig, label: "생산량" }, + { id: "B", dataSource: DataSourceConfig, label: "총재고량" }, + ] + expression: string // "A / B", "A + B", "A / B * 100" + displayFormat: "value" | "fraction" | "percent" | "ratio" + } + + // 서브타입별 설정 + kpiConfig?: { unit, colorRanges, showTrend, trendPeriod } + chartConfig?: { chartType, xAxis, yAxis, colors } + gaugeConfig?: { min, max, target, colorRanges } + statConfig?: { categories, showLink } +} +``` + +#### 설정 패널 흐름 (드롭다운 기반 쉬운 집계) + +``` +1. [+ 아이템 추가] 버튼 클릭 +2. 서브타입 선택: kpi-card / chart / gauge / stat-card +3. 데이터 모드 선택: [단일 집계] 또는 [계산식] + + [단일 집계] + - 테이블 선택 (table-schema API로 목록) + - 조인할 테이블 추가 (선택사항) + - 컬럼 선택 → 집계 함수 선택 (합계/건수/평균/최소/최대) + - 필터 조건 추가 + + [계산식] (예: 생산량/총재고량) + - 값 A: 테이블 -> 컬럼 -> 집계함수 + - 값 B: 테이블 -> 컬럼 -> 집계함수 (다른 테이블도 가능) + - 계산식: A / B + - 표시 형태: 분수 / 퍼센트 / 비율 + +4. 라벨, 단위, 색상 등 외형 설정 +5. 행열 그리드 위치 설정 (grid 모드일 때) +``` + +### 3. pop-table (신규 - 가장 복잡) + +- **한 줄 정의**: 데이터 목록을 보여주고 편집함 +- **카테고리**: display +- **역할**: 데이터 목록 표시 + 편집 (카드형/테이블형) +- **서브타입**: + - card-list: 카드 형태 + - table-list: 테이블 형태 (행/열 장부) +- **데이터**: DataSourceConfig (조인/컬럼별 읽기쓰기 자유) +- **CRUD**: useDataSource의 save/update/delete 사용. write/readwrite 컬럼만 자동 추출 +- **카드 템플릿** (card-list 전용): 카드 내부 미니 그리드로 요소 배치, 요소별 데이터 바인딩 +- **이벤트**: + - 수신: filter_changed, refresh, data_ready + - 발행: row_selected, row_action, save_complete, delete_complete +- **설정**: 데이터 소스, 표시 모드, 카드 템플릿, 컬럼 정의, 행 선택 방식, 페이징, 정렬, 인라인 편집 여부 + +### 4. pop-button (신규) + +- **한 줄 정의**: 누르면 액션 실행 (저장, 삭제 등) +- **카테고리**: action +- **역할**: 액션 실행 (저장, 삭제, API 호출, 모달 열기 등) +- **데이터**: 이벤트로 수신한 데이터를 액션에 활용 +- **CRUD**: 버튼 클릭 시 수신 데이터 기반으로 save/update/delete 실행 +- **이벤트**: + - 수신: data_ready, row_selected + - 발행: save_complete, delete_complete 등 +- **설정**: 라벨, 아이콘, 크기, 스타일, 액션 설정(PopActionConfig), 확인 다이얼로그, 로딩 상태 + +### 5. pop-icon (신규) + +- **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음) +- **카테고리**: action +- **역할**: 네비게이션 (화면 이동, URL 이동) +- **데이터**: 없음 +- **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행) +- **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시 +- **pop-lookup과의 차이**: pop-icon은 이동/실행만 함. 값을 선택해서 돌려주지 않음 + +### 6. pop-search (신규) + +- **한 줄 정의**: 조건을 입력해서 다른 컴포넌트를 조회/필터링 +- **카테고리**: input +- **역할**: 다른 컴포넌트에 필터 조건 전달 + 자체 데이터 조회 +- **서브타입**: + - text-search: 텍스트 검색 + - date-range: 날짜 범위 + - select-filter: 드롭다운 선택 (공통코드 연동) + - combo-filter: 복합 필터 (여러 조건 조합) +- **실행 방식**: auto(값 변경 즉시) 또는 button(검색 버튼 클릭 시) +- **데이터**: 공통코드/카테고리 API로 선택 항목 조회 +- **이벤트**: + - 수신: 없음 + - 발행: filter_changed (필터 값 변경 시) +- **설정**: 필터 타입, 대상 컬럼, 공통코드 연결, 플레이스홀더, 실행 방식(auto/button), 발행할 이벤트 이름 +- **pop-field와의 차이**: pop-search 입력값은 조회용(DB에 안 들어감). pop-field 입력값은 저장용(DB에 들어감) + +### 7. pop-field (신규) + +- **한 줄 정의**: 저장할 값을 입력 +- **카테고리**: input +- **역할**: 단일 데이터 입력 (폼 필드) - 입력한 값이 DB에 저장되는 것이 목적 +- **서브타입**: + - text: 텍스트 입력 + - number: 숫자 입력 (수량, 금액) + - date: 날짜 선택 + - select: 드롭다운 선택 + - numpad: 큰 숫자패드 (현장용) +- **데이터**: DataSourceConfig (선택적) + - select 옵션을 DB에서 조회 가능 + - ColumnBinding으로 입력값의 저장 대상 테이블/컬럼 지정 +- **CRUD**: 자체 저장은 보통 하지 않음. value_changed 이벤트로 pop-button 등에 전달 +- **이벤트**: + - 수신: set_value (외부에서 값 설정) + - 발행: value_changed (값 + 컬럼명 + 모드 정보) +- **설정**: 입력 타입, 라벨, 플레이스홀더, 필수 여부, 유효성 검증, 최소/최대값, 단위 표시, 바인딩 컬럼 + +### 8. pop-lookup (신규) + +- **한 줄 정의**: 모달에서 값을 골라서 반환 +- **카테고리**: input +- **역할**: 필드를 클릭하면 모달이 열리고, 목록에서 선택하면 값이 반환되는 컴포넌트 +- **서브타입 (모달 안 표시 방식)**: + - card: 카드형 목록 + - table: 테이블형 목록 + - icon-grid: 아이콘 그리드 (참조 화면의 거래처 선택처럼) +- **동작 흐름**: 필드 클릭 -> 모달 열림 -> 목록에서 선택 -> 모달 닫힘 -> 필드에 값 표시 + 이벤트 발행 +- **데이터**: DataSourceConfig (모달 안 목록의 데이터 소스) +- **이벤트**: + - 수신: set_value (외부에서 값 초기화) + - 발행: value_selected (선택한 레코드 전체 데이터 전달), filter_changed (선택 값을 필터로 전달) +- **설정**: 라벨, 플레이스홀더, 데이터 소스, 모달 표시 방식(card/table/icon-grid), 표시 컬럼(모달 목록에 보여줄 컬럼), 반환 컬럼(선택 시 돌려줄 값), 발행할 이벤트 이름 +- **pop-icon과의 차이**: pop-icon은 이동/실행만 하고 값이 안 돌아옴. pop-lookup은 값을 골라서 돌려줌 +- **pop-search와의 차이**: pop-search는 텍스트/날짜/드롭다운으로 필터링. pop-lookup은 모달을 열어서 목록에서 선택 + +#### pop-lookup 모달 화면 설계 방식 + +pop-lookup이 열리는 모달의 내부 화면은 **두 가지 방식** 중 선택할 수 있다: + +**방식 A: 인라인 모달 (기본)** +- pop-lookup 컴포넌트의 설정 패널에서 직접 모달 내부 화면을 구성 +- DataSourceConfig + 표시 컬럼 + 검색 필터 설정만으로 동작 +- 별도 화면 생성 없이 컴포넌트 설정만으로 완결 +- 적합한 경우: 단순 목록 선택 (거래처 목록, 품목 목록 등) + +**방식 B: 외부 화면 참조 (고급)** +- 별도의 POP 화면(screen_id)을 모달로 연결 +- 모달 안에서 검색/필터/테이블 등 복잡한 화면을 디자이너로 자유롭게 구성 +- 여러 pop-lookup에서 같은 모달 화면을 재사용 가능 +- 적합한 경우: 복잡한 검색/필터가 필요한 선택 화면, 여러 화면에서 공유하는 모달 + +**설정 구조:** + +``` +modalConfig: { + mode: "inline" | "screen-ref" + + // mode = "inline"일 때 사용 + dataSource: DataSourceConfig + displayColumns: ColumnBinding[] + searchFilter: { enabled: boolean, targetColumns: string[] } + modalSize: { width: number, height: number } + + // mode = "screen-ref"일 때 사용 + screenId: number // 참조할 POP 화면 ID + returnMapping: { // 모달 화면에서 선택된 값을 어떻게 매핑할지 + sourceColumn: string // 모달 화면에서 반환하는 컬럼 + targetField: string // pop-lookup 필드에 표시할 값 + }[] + modalSize: { width: number, height: number } +} +``` + +**기존 시스템과의 호환성 (검증 완료):** + +| 항목 | 현재 상태 | pop-lookup 지원 여부 | +|------|-----------|---------------------| +| DB: layout_data JSONB | 유연한 JSON 구조 | modalConfig를 layout_data에 저장 가능 (스키마 변경 불필요) | +| DB: screen_layouts_pop 테이블 | screen_id + company_code 기반 | 모달 화면도 별도 screen_id로 저장 가능 | +| 프론트: TabsWidget | screenId로 외부 화면 참조 지원 | 같은 패턴으로 모달에서 외부 화면 로드 가능 | +| 프론트: detectLinkedModals API | 연결된 모달 화면 감지 기능 있음 | 화면 간 참조 관계 추적에 활용 가능 | +| 백엔드: saveLayoutPop/getLayoutPop | POP 전용 저장/조회 API 있음 | 모달 화면도 동일 API로 저장/조회 가능 | +| 레이어 시스템 | layer_id 기반 다중 레이어 지원 | 모달 내부 레이아웃을 레이어로 관리 가능 | + +**DB 마이그레이션 불필요**: layout_data가 JSONB이므로 modalConfig를 컴포넌트 overrides에 포함하면 됨. +**백엔드 변경 불필요**: 기존 saveLayoutPop/getLayoutPop API가 그대로 사용 가능. +**프론트엔드 참고 패턴**: TabsWidget의 screenId 참조 방식을 그대로 차용. + +### 9. pop-system (신규) + +- **한 줄 정의**: 시스템 설정을 하나로 통합한 컴포넌트 (프로필, 테마, 보이기/숨기기) +- **카테고리**: system +- **역할**: 사용자 개인 설정 기능을 제공하는 통합 컴포넌트 +- **내부 포함 기능**: + - 프로필 표시 (사용자명, 부서) + - 테마 선택 (기본/다크/블루/그린) + - 대시보드 보이기/숨기기 체크박스 (같은 화면의 userConfigurable=true 컴포넌트를 자동 수집) + - 하단 메뉴 보이기/숨기기 + - 드래그앤드롭으로 순서 변경 +- **디자이너가 설정하는 것**: 크기(그리드에서 차지하는 영역), 내부 라벨/아이콘 크기와 위치 +- **사용자가 하는 것**: 체크박스로 컴포넌트 보이기/숨기기, 테마 선택, 순서 변경 +- **데이터**: 같은 화면의 layout_data에서 컴포넌트 목록을 자동 수집 +- **저장**: 사용자별 설정을 localStorage에 저장 (데스크탑 패턴 따름) +- **이벤트**: + - 수신: 없음 + - 발행: visibility_changed (컴포넌트 보이기/숨기기 변경 시), theme_changed (테마 변경 시) +- **설정**: 내부 라벨 크기, 아이콘 크기, 위치 정도만 +- **특이사항**: + - 디자이너가 이 컴포넌트를 배치하지 않으면 해당 화면에 개인 설정 기능이 없다 + - 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정하는 구조 + - 메인 홈에는 배치, 업무 화면(입고 등)에는 안 배치하는 식으로 사용 + +--- + +## 컴포넌트 간 통신 예시 + +### 예시 1: 검색 -> 필터 연동 + +```mermaid +sequenceDiagram + participant Search as pop-search + participant Dashboard as pop-dashboard + participant Table as pop-table + + Note over Search: 사용자가 창고 WH01 선택 + Search->>Dashboard: filter_changed + Search->>Table: filter_changed + Note over Dashboard: DataSource 재조회 + Note over Table: DataSource 재조회 +``` + +### 예시 2: 데이터 전달 + 선택적 저장 + +```mermaid +sequenceDiagram + participant Table as pop-table + participant Field as pop-field + participant Button as pop-button + + Note over Table: 사용자가 발주 행 선택 + Table->>Field: row_selected + Table->>Button: row_selected + Note over Field: 사용자가 qty를 500으로 입력 + Field->>Button: value_changed + Note over Button: 사용자가 저장 클릭 + Note over Button: write/readwrite 컬럼만 추출하여 저장 + Button->>Table: save_complete + Note over Table: 데이터 새로고침 +``` + +### 예시 3: pop-lookup 거래처 선택 -> 품목 조회 + +```mermaid +sequenceDiagram + participant Lookup as pop-lookup + participant Table as pop-table + + Note over Lookup: 사용자가 거래처 필드 클릭 + Note over Lookup: 모달 열림 - 거래처 목록 표시 + Note over Lookup: 사용자가 대한금속 선택 + Note over Lookup: 모달 닫힘 - 필드에 대한금속 표시 + Lookup->>Table: filter_changed { company: "대한금속" } + Note over Table: company=대한금속 필터로 재조회 + Note over Table: 발주 품목 3건 표시 +``` + +### 예시 4: pop-lookup 인라인 모달 vs 외부 화면 참조 + +```mermaid +sequenceDiagram + participant User as 사용자 + participant Lookup as pop-lookup (거래처) + participant Modal as 모달 + + Note over User,Modal: [방식 A: 인라인 모달] + User->>Lookup: 거래처 필드 클릭 + Lookup->>Modal: 인라인 모달 열림 (DataSourceConfig 기반) + Note over Modal: supplier 테이블에서 목록 조회 + Note over Modal: 테이블형 목록 표시 + User->>Modal: "대한금속" 선택 + Modal->>Lookup: value_selected { supplier_code: "DH001", name: "대한금속" } + Note over Lookup: 필드에 "대한금속" 표시 + + Note over User,Modal: [방식 B: 외부 화면 참조] + User->>Lookup: 거래처 필드 클릭 + Lookup->>Modal: 모달 열림 (screenId=42 화면 로드) + Note over Modal: 별도 POP 화면 렌더링 + Note over Modal: pop-search(검색) + pop-table(목록) 등 배치된 컴포넌트 동작 + User->>Modal: 검색 후 "대한금속" 선택 + Modal->>Lookup: returnMapping 기반으로 값 반환 + Note over Lookup: 필드에 "대한금속" 표시 +``` + +### 예시 5: 컬럼별 읽기/쓰기 분리 동작 + +5개 컬럼이 있는 발주 화면: + +- item_code (read) -> 화면에 표시, 저장 안 함 +- item_name (read, 조인) -> item_info 테이블에서 가져옴, 저장 안 함 +- inbound_qty (readwrite) -> 화면에 표시 + 사용자 수정 + 저장 +- warehouse (write) -> 사용자 입력 + 저장 +- memo (write) -> 사용자 입력 + 저장 + +저장 API 호출 시: `{ inbound_qty: 500, warehouse: "WH01", memo: "긴급" }` 만 전달 +조회 API 호출 시: 5개 컬럼 전부 + 조인된 item_name까지 조회 + +--- + +## 구현 우선순위 + +- Phase 0 (공통 인프라): ColumnBinding, JoinConfig, DataSourceConfig 타입, useDataSource 훅 (CRUD 포함), usePopEvent 훅 (데이터 전달 포함), PopActionConfig 타입 +- Phase 1 (기본 표시): pop-dashboard (4개 서브타입 전부 + 멀티 아이템 컨테이너 + 4개 표시 모드 + 계산식) +- Phase 2 (기본 액션): pop-button, pop-icon +- Phase 3 (데이터 목록): pop-table (테이블형부터, 카드형은 후순위) +- Phase 4 (입력/연동): pop-search, pop-field, pop-lookup +- Phase 5 (고도화): pop-table 카드 템플릿 +- Phase 6 (시스템): pop-system (프로필, 테마, 대시보드 보이기/숨기기 통합) + +### Phase 1 상세 변경 (2026-02-09 토의 결정) + +기존 계획에서 "KPI 카드 우선"이었으나, 토의 결과 **4개 서브타입 전부를 Phase 1에서 구현**으로 변경: +- kpi-card, chart, gauge, stat-card 모두 Phase 1 +- 멀티 아이템 컨테이너 (arrows, auto-slide, grid, scroll) +- 계산식 지원 (formula) +- 드롭다운 기반 쉬운 집계 설정 +- 기존 `frontend/components/pop/dashboard/` 폴더는 Phase 1 완료 후 폐기/삭제 + +### 백엔드 API 현황 (호환성 점검 완료) + +기존 백엔드에 이미 구현되어 있어 새로 만들 필요 없는 API: + +| API | 용도 | 비고 | +|-----|------|------| +| `dataApi.getTableData()` | 동적 테이블 조회 | 페이징, 검색, 정렬, 필터 | +| `dataApi.getJoinedData()` | 2개 테이블 조인 | Entity 조인, 필터링, 중복제거 | +| `entityJoinApi.getTableDataWithJoins()` | Entity 조인 전용 | ID->이름 자동 변환 | +| `dataApi.createRecord/updateRecord/deleteRecord()` | 동적 CRUD | - | +| `dataApi.upsertGroupedRecords()` | 그룹 UPSERT | - | +| `dashboardApi.executeQuery()` | SELECT SQL 직접 실행 | 집계/복합조인용 | +| `dashboardApi.getTableSchema()` | 테이블/컬럼 목록 | 설정 패널 드롭다운용 | + +**백엔드 신규 개발 불필요** - 기존 API만으로 모든 데이터 연동 가능 + +### useDataSource의 API 선택 전략 + +``` +단순 조회 (조인/집계 없음) -> dataApi.getTableData() 또는 entityJoinApi +2개 테이블 조인 -> dataApi.getJoinedData() +3개+ 테이블 조인 또는 집계 -> DataSourceConfig를 SQL로 변환 -> dashboardApi.executeQuery() +CRUD -> dataApi.createRecord/updateRecord/deleteRecord() +``` + +### POP 전용 훅 분리 (2026-02-09 결정) + +데스크탑과의 완전 분리를 위해 POP 전용 훅은 별도 폴더: +- `frontend/hooks/pop/usePopEvent.ts` (POP 전용) +- `frontend/hooks/pop/useDataSource.ts` (POP 전용) + +## 기존 시스템 호환성 검증 결과 (v8.0 추가) + +v8.0에서 추가된 모달 설계 방식에 대해 기존 시스템과의 호환성을 검증한 결과: + +### DB 스키마 (변경 불필요) + +| 테이블 | 현재 구조 | 호환성 | +|--------|-----------|--------| +| screen_layouts_v2 | layout_data JSONB + screen_id + company_code + layer_id | modalConfig를 컴포넌트 overrides에 포함하면 됨 | +| screen_layouts_pop | 동일 구조 (POP 전용) | 모달 화면도 별도 screen_id로 저장 가능 | + +- layout_data가 JSONB 타입이므로 어떤 JSON 구조든 저장 가능 +- 모달 화면을 별도 screen_id로 만들어도 기존 UNIQUE(screen_id, company_code, layer_id) 제약조건과 충돌 없음 +- DB 마이그레이션 불필요 + +### 백엔드 API (변경 불필요) + +| API | 엔드포인트 | 호환성 | +|-----|-----------|--------| +| POP 레이아웃 저장 | POST /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 저장 | +| POP 레이아웃 조회 | GET /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 조회 | +| 연결 모달 감지 | detectLinkedModals(screenId) | 화면 간 참조 관계 추적에 활용 | + +### 프론트엔드 (참고 패턴 존재) + +| 기존 기능 | 위치 | 활용 방안 | +|-----------|------|-----------| +| TabsWidget screenId 참조 | frontend/components/screen/widgets/TabsWidget.tsx | 같은 패턴으로 모달에서 외부 화면 로드 | +| TabsConfigPanel | frontend/components/screen/config-panels/TabsConfigPanel.tsx | pop-lookup 설정 패널의 모달 화면 선택 UI 참조 | +| ScreenDesigner 탭 내부 컴포넌트 | frontend/components/screen/ScreenDesigner.tsx | 모달 내부 컴포넌트 편집 패턴 참조 | + +### 결론 + +- DB 마이그레이션: 불필요 +- 백엔드 변경: 불필요 +- 프론트엔드: pop-lookup 컴포넌트 구현 시 기존 TabsWidget의 screenId 참조 패턴을 그대로 차용 +- 새로운 API: 불필요 (기존 saveLayoutPop/getLayoutPop로 충분) + +## 참고 파일 + +- 레지스트리: `frontend/lib/registry/PopComponentRegistry.ts` +- 기존 텍스트 컴포넌트: `frontend/lib/registry/pop-components/pop-text.tsx` +- 공통 스타일 타입: `frontend/lib/registry/pop-components/types.ts` +- POP 타입 정의: `frontend/components/pop/designer/types/pop-layout.ts` +- 기존 스펙 (v4): `popdocs/components-spec.md` +- 탭 위젯 (모달 참조 패턴): `frontend/components/screen/widgets/TabsWidget.tsx` +- POP 레이아웃 API: `frontend/lib/api/screen.ts` (saveLayoutPop, getLayoutPop) +- 백엔드 화면관리: `backend-node/src/controllers/screenManagementController.ts` diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 00000000..09b8da12 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,46 @@ +# 프로젝트 상태 추적 + +> **최종 업데이트**: 2026-02-11 + +--- + +## 현재 진행 중 + +### pop-dashboard 스타일 정리 +**상태**: 코딩 완료, 브라우저 확인 대기 +**계획서**: [popdocs/PLAN.md](./popdocs/PLAN.md) +**내용**: 글자 크기 커스텀 제거 + 라벨 정렬만 유지 + stale closure 수정 + +--- + +## 다음 작업 + +| 순서 | 작업 | 상태 | +|------|------|------| +| 1 | pop-card-list 입력 필드/계산 필드 구조 개편 (PLAN.MD 참고) | [ ] 코딩 대기 | +| 2 | pop-card-list 담기 버튼 독립화 (보류) | [ ] 대기 | +| 3 | pop-card-list 반응형 표시 런타임 적용 | [ ] 대기 | + +--- + +## 완료된 작업 (최근) + +| 날짜 | 작업 | 비고 | +|------|------|------| +| 2026-02-11 | 대시보드 스타일 정리 | FONT_SIZE_PX/글자 크기 Select 삭제, ItemStyleConfig -> labelAlign만, stale closure 수정 | +| 2026-02-10 | 디자이너 캔버스 UX 개선 | 헤더 제거, 실제 데이터 렌더링, 컴포넌트 목록 | +| 2026-02-10 | 차트/게이지/네비게이션/정렬 디자인 개선 | CartesianGrid, abbreviateNumber, 오버레이 화살표/인디케이터 | +| 2026-02-10 | 대시보드 4가지 아이템 모드 완성 | groupBy UI, xAxisColumn, 통계카드 카테고리, 필터 버그 수정 | +| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 | +| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent | +| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 | + +--- + +## 알려진 이슈 + +| # | 이슈 | 심각도 | 상태 | +|---|------|--------|------| +| 1 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 | +| 2 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 | +| 3 | 기존 저장 데이터의 `itemStyle.align`이 `labelAlign`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 | diff --git a/backend-node/README.md b/backend-node/README.md index a2d34209..84bff2a1 100644 --- a/backend-node/README.md +++ b/backend-node/README.md @@ -1,4 +1,4 @@ -# PLM System Backend - Node.js + TypeScript +re# PLM System Backend - Node.js + TypeScript Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백엔드입니다. diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 4e6da57e..4b3d212a 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -124,6 +124,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"; // 임시 주석 @@ -306,6 +307,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/bomController.ts b/backend-node/src/controllers/bomController.ts index 3508fca4..b98baad1 100644 --- a/backend-node/src/controllers/bomController.ts +++ b/backend-node/src/controllers/bomController.ts @@ -143,6 +143,70 @@ export async function initializeBomVersion(req: Request, res: Response) { } } +// ─── BOM 엑셀 업로드/다운로드 ───────────────────────── + +export async function createBomFromExcel(req: Request, res: Response) { + try { + const companyCode = (req as any).user?.companyCode || "*"; + const userId = (req as any).user?.userName || (req as any).user?.userId || ""; + const { rows } = req.body; + + if (!rows || !Array.isArray(rows) || rows.length === 0) { + res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" }); + return; + } + + const result = await bomService.createBomFromExcel(companyCode, userId, rows); + if (!result.success) { + res.status(400).json({ success: false, message: result.errors.join(", "), data: result }); + return; + } + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 엑셀 업로드 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createBomVersionFromExcel(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const userId = (req as any).user?.userName || (req as any).user?.userId || ""; + const { rows, versionName } = req.body; + + if (!rows || !Array.isArray(rows) || rows.length === 0) { + res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" }); + return; + } + + const result = await bomService.createBomVersionFromExcel(bomId, companyCode, userId, rows, versionName); + if (!result.success) { + res.status(400).json({ success: false, message: result.errors.join(", "), data: result }); + return; + } + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 버전 엑셀 업로드 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function downloadBomExcelData(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + + const data = await bomService.downloadBomExcelData(bomId, companyCode); + res.json({ success: true, data }); + } catch (error: any) { + logger.error("BOM 엑셀 다운로드 데이터 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + export async function deleteBomVersion(req: Request, res: Response) { try { const { bomId, versionId } = req.params; diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 3ece2ce7..62fc8bbe 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -3,16 +3,115 @@ import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +/** + * 필터 조건을 WHERE절에 적용하는 공통 헬퍼 + * filters JSON 배열: [{ column, operator, value }] + */ +function applyFilters( + filtersJson: string | undefined, + existingColumns: Set, + whereConditions: string[], + params: any[], + startParamIndex: number, + tableName: string, +): number { + let paramIndex = startParamIndex; + + if (!filtersJson) return paramIndex; + + let filters: Array<{ column: string; operator: string; value: unknown }>; + try { + filters = JSON.parse(filtersJson as string); + } catch { + logger.warn("filters JSON 파싱 실패", { tableName, filtersJson }); + return paramIndex; + } + + if (!Array.isArray(filters)) return paramIndex; + + for (const filter of filters) { + const { column, operator = "=", value } = filter; + if (!column || !existingColumns.has(column)) { + logger.warn("필터 컬럼 미존재 제외", { tableName, column }); + continue; + } + + switch (operator) { + case "=": + whereConditions.push(`"${column}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "!=": + whereConditions.push(`"${column}" != $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case ">": + case "<": + case ">=": + case "<=": + whereConditions.push(`"${column}" ${operator} $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "in": { + const inVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (inVals.length > 0) { + const ph = inVals.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${column}" IN (${ph})`); + params.push(...inVals); + paramIndex += inVals.length; + } + break; + } + case "notIn": { + const notInVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (notInVals.length > 0) { + const ph = notInVals.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${column}" NOT IN (${ph})`); + params.push(...notInVals); + paramIndex += notInVals.length; + } + break; + } + case "like": + whereConditions.push(`"${column}"::text ILIKE $${paramIndex}`); + params.push(`%${value}%`); + paramIndex++; + break; + case "isNull": + whereConditions.push(`"${column}" IS NULL`); + break; + case "isNotNull": + whereConditions.push(`"${column}" IS NOT NULL`); + break; + default: + whereConditions.push(`"${column}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + } + } + + return paramIndex; +} + /** * 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용) * GET /api/entity/:tableName/distinct/:columnName * * 해당 테이블의 해당 컬럼에서 DISTINCT 값을 조회하여 선택박스 옵션으로 반환 + * + * Query Params: + * - labelColumn: 별도의 라벨 컬럼 (선택) + * - filters: JSON 배열 형태의 필터 조건 (선택) + * 예: [{"column":"status","operator":"=","value":"active"}] */ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) { try { const { tableName, columnName } = req.params; - const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼 + const { labelColumn, filters: filtersParam } = req.query; // 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -68,6 +167,16 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re whereConditions.push(`"${columnName}" IS NOT NULL`); whereConditions.push(`"${columnName}" != ''`); + // 필터 조건 적용 + paramIndex = applyFilters( + filtersParam as string | undefined, + existingColumns, + whereConditions, + params, + paramIndex, + tableName, + ); + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; @@ -88,6 +197,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re columnName, labelColumn: effectiveLabelColumn, companyCode, + hasFilters: !!filtersParam, rowCount: result.rowCount, }); @@ -111,11 +221,14 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re * Query Params: * - value: 값 컬럼 (기본: id) * - label: 표시 컬럼 (기본: name) + * - fields: 추가 반환 컬럼 (콤마 구분) + * - filters: JSON 배열 형태의 필터 조건 (선택) + * 예: [{"column":"status","operator":"=","value":"active"}] */ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) { try { const { tableName } = req.params; - const { value = "id", label = "name", fields } = req.query; + const { value = "id", label = "name", fields, filters: filtersParam } = req.query; // tableName 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -163,6 +276,16 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) paramIndex++; } + // 필터 조건 적용 + paramIndex = applyFilters( + filtersParam as string | undefined, + existingColumns, + whereConditions, + params, + paramIndex, + tableName, + ); + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; @@ -195,6 +318,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) valueColumn, labelColumn: effectiveLabelColumn, companyCode, + hasFilters: !!filtersParam, rowCount: result.rowCount, extraFields: extraColumns ? true : false, }); diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts new file mode 100644 index 00000000..e72f6b9f --- /dev/null +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -0,0 +1,713 @@ +/** + * 공정 작업기준 컨트롤러 + * 품목별 라우팅/공정에 대한 작업 항목 및 상세 관리 + */ + +import { Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; + +// ============================================================ +// 품목/라우팅/공정 조회 (좌측 트리 데이터) +// ============================================================ + +/** + * 라우팅이 있는 품목 목록 조회 + * 요청 쿼리: tableName(품목테이블), nameColumn, codeColumn + */ +export async function getItemsWithRouting(req: AuthenticatedRequest, 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 + i.id, + i.${nameColumn} AS item_name, + i.${codeColumn} AS item_code, + COUNT(rv.id) AS routing_count + FROM ${tableName} i + LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} + AND rv.company_code = i.company_code + WHERE i.company_code = $1 + ${searchCondition} + GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date + ORDER BY i.created_date DESC NULLS LAST + `; + + 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: AuthenticatedRequest, 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, COALESCE(is_default, false) AS is_default + FROM ${routingVersionTable} + WHERE ${routingFkColumn} = $1 AND company_code = $2 + ORDER BY is_default DESC, 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 }); + } +} + +// ============================================================ +// 기본 버전 설정 +// ============================================================ + +/** + * 라우팅 버전을 기본 버전으로 설정 + * 같은 품목의 다른 버전은 기본 해제 + */ +export async function setDefaultVersion(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { versionId } = req.params; + const { + routingVersionTable = "item_routing_version", + routingFkColumn = "item_code", + } = req.body; + + await client.query("BEGIN"); + + const versionResult = await client.query( + `SELECT ${routingFkColumn} AS item_code FROM ${routingVersionTable} WHERE id = $1 AND company_code = $2`, + [versionId, companyCode] + ); + + if (versionResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" }); + } + + const itemCode = versionResult.rows[0].item_code; + + await client.query( + `UPDATE ${routingVersionTable} SET is_default = false WHERE ${routingFkColumn} = $1 AND company_code = $2`, + [itemCode, companyCode] + ); + + await client.query( + `UPDATE ${routingVersionTable} SET is_default = true WHERE id = $1 AND company_code = $2`, + [versionId, companyCode] + ); + + await client.query("COMMIT"); + + logger.info("기본 버전 설정", { companyCode, versionId, itemCode }); + 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(); + } +} + +/** + * 기본 버전 해제 + */ +export async function unsetDefaultVersion(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { versionId } = req.params; + const { routingVersionTable = "item_routing_version" } = req.body; + + await getPool().query( + `UPDATE ${routingVersionTable} SET is_default = false WHERE id = $1 AND company_code = $2`, + [versionId, companyCode] + ); + + logger.info("기본 버전 해제", { companyCode, versionId }); + return res.json({ success: true, message: "기본 버전이 해제되었습니다" }); + } 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: AuthenticatedRequest, 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: AuthenticatedRequest, 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: AuthenticatedRequest, 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: AuthenticatedRequest, 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: AuthenticatedRequest, 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, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields, + 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: AuthenticatedRequest, 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, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields, + } = 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, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING * + `; + + const result = await getPool().query(query, [ + companyCode, + work_item_id, + detail_type || null, + content, + is_required || "N", + sort_order || 0, + remark || null, + writer, + inspection_code || null, + inspection_method || null, + unit || null, + lower_limit || null, + upper_limit || null, + duration_minutes || null, + input_type || null, + lookup_target || null, + display_fields || null, + ]); + + 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: AuthenticatedRequest, 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, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields, + } = 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), + inspection_code = $8, + inspection_method = $9, + unit = $10, + lower_limit = $11, + upper_limit = $12, + duration_minutes = $13, + input_type = $14, + lookup_target = $15, + display_fields = $16, + 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, + inspection_code || null, + inspection_method || null, + unit || null, + lower_limit || null, + upper_limit || null, + duration_minutes || null, + input_type || null, + lookup_target || null, + display_fields || null, + ]); + + 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: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { id } = req.params; + + const result = await getPool().query( + "DELETE FROM 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: AuthenticatedRequest, 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, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, + [ + companyCode, + workItemId, + detail.detail_type || null, + detail.content, + detail.is_required || "N", + detail.sort_order || 0, + detail.remark || null, + writer, + detail.inspection_code || null, + detail.inspection_method || null, + detail.unit || null, + detail.lower_limit || null, + detail.upper_limit || null, + detail.duration_minutes || null, + detail.input_type || null, + detail.lookup_target || null, + detail.display_fields || null, + ] + ); + } + } + } + + 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/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 430ccfa0..8cd9f770 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -921,6 +921,42 @@ export async function addTableData( } } + // 회사별 NOT NULL 소프트 제약조건 검증 + const notNullViolations = await tableManagementService.validateNotNullConstraints( + tableName, + data, + companyCode || "*" + ); + if (notNullViolations.length > 0) { + res.status(400).json({ + success: false, + message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`, + error: { + code: "NOT_NULL_VIOLATION", + details: notNullViolations, + }, + }); + return; + } + + // 회사별 UNIQUE 소프트 제약조건 검증 + const uniqueViolations = await tableManagementService.validateUniqueConstraints( + tableName, + data, + companyCode || "*" + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + error: { + code: "UNIQUE_VIOLATION", + details: uniqueViolations, + }, + }); + return; + } + // 데이터 추가 const result = await tableManagementService.addTableData(tableName, data); @@ -1004,6 +1040,45 @@ export async function editTableData( } const tableManagementService = new TableManagementService(); + const companyCode = req.user?.companyCode || "*"; + + // 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상) + const notNullViolations = await tableManagementService.validateNotNullConstraints( + tableName, + updatedData, + companyCode + ); + if (notNullViolations.length > 0) { + res.status(400).json({ + success: false, + message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`, + error: { + code: "NOT_NULL_VIOLATION", + details: notNullViolations, + }, + }); + return; + } + + // 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외) + const excludeId = originalData?.id ? String(originalData.id) : undefined; + const uniqueViolations = await tableManagementService.validateUniqueConstraints( + tableName, + updatedData, + companyCode, + excludeId + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + error: { + code: "UNIQUE_VIOLATION", + details: uniqueViolations, + }, + }); + return; + } // 데이터 수정 await tableManagementService.editTableData( @@ -1694,6 +1769,7 @@ export async function getCategoryColumnsByCompany( let columnsResult; // 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회 + // category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함) if (companyCode === "*") { const columnsQuery = ` SELECT DISTINCT @@ -1713,15 +1789,15 @@ export async function getCategoryColumnsByCompany( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = '*' + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery); - logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", { + logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", { rowCount: columnsResult.rows.length }); } else { - // 일반 회사: 해당 회사의 카테고리 컬럼만 조회 const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", @@ -1740,11 +1816,12 @@ export async function getCategoryColumnsByCompany( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = $1 + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery, [companyCode]); - logger.info("✅ 회사별 카테고리 컬럼 조회 완료", { + logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", { companyCode, rowCount: columnsResult.rows.length }); @@ -1805,13 +1882,10 @@ export async function getCategoryColumnsByMenu( const { getPool } = await import("../database/db"); const pool = getPool(); - // 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회 - // category_column_mapping 대신 table_type_columns 기준으로 조회 - logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); - + // table_type_columns에서 input_type = 'category' 컬럼 조회 + // category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함) let columnsResult; - // 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회 if (companyCode === "*") { const columnsQuery = ` SELECT DISTINCT @@ -1831,15 +1905,15 @@ export async function getCategoryColumnsByMenu( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = '*' + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery); - logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", { + logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", { rowCount: columnsResult.rows.length }); } else { - // 일반 회사: 해당 회사의 카테고리 컬럼만 조회 const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", @@ -1858,11 +1932,12 @@ export async function getCategoryColumnsByMenu( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = $1 + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery, [companyCode]); - logger.info("✅ 회사별 카테고리 컬럼 조회 완료", { + logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", { companyCode, rowCount: columnsResult.rows.length }); @@ -2617,8 +2692,22 @@ export async function toggleTableIndex( logger.info(`인덱스 ${action}: ${indexName} (${indexType})`); if (action === "create") { + let indexColumns = `"${columnName}"`; + + // 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장) + if (indexType === "unique") { + const hasCompanyCode = await query( + `SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + if (hasCompanyCode.length > 0) { + indexColumns = `"company_code", "${columnName}"`; + logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`); + } + } + const uniqueClause = indexType === "unique" ? "UNIQUE " : ""; - const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`; + const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`; logger.info(`인덱스 생성: ${sql}`); await query(sql); } else if (action === "drop") { @@ -2639,22 +2728,55 @@ export async function toggleTableIndex( } catch (error: any) { logger.error("인덱스 토글 오류:", error); - // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내 - const errorMsg = error.message?.includes("duplicate key") - ? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요." - : "인덱스 설정 중 오류가 발생했습니다."; + const errMsg = error.message || ""; + let userMessage = "인덱스 설정 중 오류가 발생했습니다."; + let duplicates: any[] = []; + + // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 + if ( + errMsg.includes("could not create unique index") || + errMsg.includes("duplicate key") + ) { + const { columnName, tableName } = { ...req.params, ...req.body }; + try { + duplicates = await query( + `SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10` + ); + } catch { + try { + duplicates = await query( + `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10` + ); + } catch { /* 중복 조회 실패 시 무시 */ } + } + + const dupDetails = duplicates.length > 0 + ? duplicates.map((d: any) => { + const company = d.company_code ? `[${d.company_code}] ` : ""; + return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`; + }).join(", ") + : ""; + + userMessage = dupDetails + ? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}` + : `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`; + } res.status(500).json({ success: false, - message: errorMsg, - error: error instanceof Error ? error.message : "Unknown error", + message: userMessage, + error: errMsg, + duplicates, }); } } /** - * NOT NULL 토글 + * NOT NULL 토글 (회사별 소프트 제약조건) * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + * + * DB 레벨 ALTER TABLE 대신 table_type_columns.is_nullable을 회사별로 관리한다. + * 멀티테넌시 환경에서 회사 A는 NOT NULL, 회사 B는 NULL 허용이 가능하다. */ export async function toggleColumnNullable( req: AuthenticatedRequest, @@ -2663,6 +2785,7 @@ export async function toggleColumnNullable( try { const { tableName, columnName } = req.params; const { nullable } = req.body; + const companyCode = req.user?.companyCode || "*"; if (!tableName || !columnName || typeof nullable !== "boolean") { res.status(400).json({ @@ -2672,18 +2795,54 @@ export async function toggleColumnNullable( return; } - if (nullable) { - // NOT NULL 해제 - const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`; - logger.info(`NOT NULL 해제: ${sql}`); - await query(sql); - } else { - // NOT NULL 설정 - const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`; - logger.info(`NOT NULL 설정: ${sql}`); - await query(sql); + // is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL + const isNullableValue = nullable ? "Y" : "N"; + + if (!nullable) { + // NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인 + const hasCompanyCode = await query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + if (hasCompanyCode.length > 0) { + const nullCheckQuery = companyCode === "*" + ? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL` + : `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`; + const nullCheckParams = companyCode === "*" ? [] : [companyCode]; + + const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams); + const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10); + + if (nullCount > 0) { + logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, { + companyCode, + nullCount, + }); + + res.status(400).json({ + success: false, + message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`, + }); + return; + } + } } + // table_type_columns에 회사별 is_nullable 설정 UPSERT + await query( + `INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + ON CONFLICT (table_name, column_name, company_code) + DO UPDATE SET is_nullable = $3, updated_date = NOW()`, + [tableName, columnName, isNullableValue, companyCode] + ); + + logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, { + companyCode, + }); + res.status(200).json({ success: true, message: nullable @@ -2693,14 +2852,95 @@ export async function toggleColumnNullable( } catch (error: any) { logger.error("NOT NULL 토글 오류:", error); - // NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내 - const errorMsg = error.message?.includes("contains null values") - ? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요." - : "NOT NULL 설정 중 오류가 발생했습니다."; - res.status(500).json({ success: false, - message: errorMsg, + message: "NOT NULL 설정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * UNIQUE 토글 (회사별 소프트 제약조건) + * PUT /api/table-management/tables/:tableName/columns/:columnName/unique + * + * DB 레벨 인덱스 대신 table_type_columns.is_unique를 회사별로 관리한다. + * 저장 시 앱 레벨에서 중복 검증을 수행한다. + */ +export async function toggleColumnUnique( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { unique } = req.body; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName || !columnName || typeof unique !== "boolean") { + res.status(400).json({ + success: false, + message: "tableName, columnName, unique(boolean)이 필요합니다.", + }); + return; + } + + const isUniqueValue = unique ? "Y" : "N"; + + if (unique) { + // UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인 + const hasCompanyCode = await query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + if (hasCompanyCode.length > 0) { + const dupQuery = companyCode === "*" + ? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10` + : `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`; + const dupParams = companyCode === "*" ? [] : [companyCode]; + + const dupResult = await query(dupQuery, dupParams); + + if (dupResult.length > 0) { + const dupDetails = dupResult + .map((d: any) => `"${d[columnName]}" (${d.cnt}건)`) + .join(", "); + + res.status(400).json({ + success: false, + message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`, + }); + return; + } + } + } + + // table_type_columns에 회사별 is_unique 설정 UPSERT + await query( + `INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + ON CONFLICT (table_name, column_name, company_code) + DO UPDATE SET is_unique = $3, updated_date = NOW()`, + [tableName, columnName, isUniqueValue, companyCode] + ); + + logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, { + companyCode, + }); + + res.status(200).json({ + success: true, + message: unique + ? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.` + : `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`, + }); + } catch (error: any) { + logger.error("UNIQUE 토글 오류:", error); + + res.status(500).json({ + success: false, + message: "UNIQUE 설정 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts index 4aa8838d..ccdbad64 100644 --- a/backend-node/src/routes/bomRoutes.ts +++ b/backend-node/src/routes/bomRoutes.ts @@ -17,6 +17,11 @@ router.get("/:bomId/header", bomController.getBomHeader); router.get("/:bomId/history", bomController.getBomHistory); router.post("/:bomId/history", bomController.addBomHistory); +// 엑셀 업로드/다운로드 +router.post("/excel-upload", bomController.createBomFromExcel); +router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel); +router.get("/:bomId/excel-download", bomController.downloadBomExcelData); + // 버전 router.get("/:bomId/versions", bomController.getBomVersions); router.post("/:bomId/versions", bomController.createBomVersion); diff --git a/backend-node/src/routes/processWorkStandardRoutes.ts b/backend-node/src/routes/processWorkStandardRoutes.ts new file mode 100644 index 00000000..7630b359 --- /dev/null +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -0,0 +1,36 @@ +/** + * 공정 작업기준 라우트 + */ + +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/processWorkStandardController"; + +const router = express.Router(); + +router.use(authenticateToken); + +// 품목/라우팅/공정 조회 (좌측 트리) +router.get("/items", ctrl.getItemsWithRouting); +router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses); + +// 기본 버전 설정/해제 +router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion); +router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion); + +// 작업 항목 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/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index d02a5615..a8964e99 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -32,6 +32,7 @@ import { setTablePrimaryKey, // 🆕 PK 설정 toggleTableIndex, // 🆕 인덱스 토글 toggleColumnNullable, // 🆕 NOT NULL 토글 + toggleColumnUnique, // 🆕 UNIQUE 토글 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -161,6 +162,12 @@ router.post("/tables/:tableName/indexes", toggleTableIndex); */ router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable); +/** + * UNIQUE 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/unique + */ +router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique); + /** * 테이블 존재 여부 확인 * GET /api/table-management/tables/:tableName/exists diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index f1d6fd84..4178dc92 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -322,6 +322,485 @@ export async function initializeBomVersion( }); } +// ─── BOM 엑셀 업로드 ───────────────────────────── + +interface BomExcelRow { + level: number; + item_number: string; + item_name?: string; + quantity: number; + unit?: string; + process_type?: string; + remark?: string; +} + +interface BomExcelUploadResult { + success: boolean; + insertedCount: number; + skippedCount: number; + errors: string[]; + unmatchedItems: string[]; + createdBomId?: string; +} + +/** + * BOM 엑셀 업로드 - 새 BOM 생성 + * + * 엑셀 레벨 체계: + * 레벨 0 = BOM 마스터 (최상위 품목) → bom 테이블에 INSERT + * 레벨 1 = 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0) + * 레벨 2 = 자품목의 자품목 → bom_detail (parent_detail_id=부모ID, DB level=1) + * 레벨 N = ... → bom_detail (DB level=N-1) + */ +export async function createBomFromExcel( + companyCode: string, + userId: string, + rows: BomExcelRow[], +): Promise { + const result: BomExcelUploadResult = { + success: false, + insertedCount: 0, + skippedCount: 0, + errors: [], + unmatchedItems: [], + }; + + if (!rows || rows.length === 0) { + result.errors.push("업로드할 데이터가 없습니다"); + return result; + } + + const headerRow = rows.find(r => r.level === 0); + const detailRows = rows.filter(r => r.level > 0); + + if (!headerRow) { + result.errors.push("레벨 0(BOM 마스터) 행이 필요합니다"); + return result; + } + if (!headerRow.item_number?.trim()) { + result.errors.push("레벨 0(BOM 마스터)의 품번은 필수입니다"); + return result; + } + if (detailRows.length === 0) { + result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)"); + return result; + } + + // 레벨 유효성 검사 + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (row.level < 0) { + result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`); + } + if (i > 0 && row.level > rows[i - 1].level + 1) { + result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다 (현재: ${row.level}, 이전: ${rows[i - 1].level})`); + } + if (row.level > 0 && !row.item_number?.trim()) { + result.errors.push(`${i + 1}행: 품번은 필수입니다`); + } + } + + if (result.errors.length > 0) { + return result; + } + + return transaction(async (client) => { + // 1. 모든 품번 일괄 조회 (헤더 + 디테일) + const allItemNumbers = [...new Set(rows.filter(r => r.item_number?.trim()).map(r => r.item_number.trim()))]; + const itemLookup = await client.query( + `SELECT id, item_number, item_name, unit FROM item_info + WHERE company_code = $1 AND item_number = ANY($2::text[])`, + [companyCode, allItemNumbers], + ); + + const itemMap = new Map(); + for (const item of itemLookup.rows) { + itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit }); + } + + for (const num of allItemNumbers) { + if (!itemMap.has(num)) { + result.unmatchedItems.push(num); + } + } + if (result.unmatchedItems.length > 0) { + result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`); + return result; + } + + // 2. bom 마스터 생성 (레벨 0) + const headerItemInfo = itemMap.get(headerRow.item_number.trim())!; + + // 동일 품목으로 이미 BOM이 존재하는지 확인 + const dupCheck = await client.query( + `SELECT id FROM bom WHERE item_id = $1 AND company_code = $2 AND status = 'active'`, + [headerItemInfo.id, companyCode], + ); + if (dupCheck.rows.length > 0) { + result.errors.push(`해당 품목(${headerRow.item_number})으로 등록된 BOM이 이미 존재합니다`); + return result; + } + + const bomInsert = await client.query( + `INSERT INTO bom (item_id, item_code, item_name, base_qty, unit, version, status, remark, writer, company_code) + VALUES ($1, $2, $3, $4, $5, '1.0', 'active', $6, $7, $8) + RETURNING id`, + [ + headerItemInfo.id, + headerRow.item_number.trim(), + headerItemInfo.item_name, + String(headerRow.quantity || 1), + headerRow.unit || headerItemInfo.unit || null, + headerRow.remark || null, + userId, + companyCode, + ], + ); + const newBomId = bomInsert.rows[0].id; + result.createdBomId = newBomId; + + // 3. bom_version 생성 + const versionInsert = await client.query( + `INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code) + VALUES ($1, '1.0', 0, 'active', $2, $3) RETURNING id`, + [newBomId, userId, companyCode], + ); + const versionId = versionInsert.rows[0].id; + + await client.query( + `UPDATE bom SET current_version_id = $1 WHERE id = $2`, + [versionId, newBomId], + ); + + // 4. bom_detail INSERT (레벨 1+ → DB level = 엑셀 level - 1) + const levelStack: string[] = []; + const seqCounterByParent = new Map(); + + for (let i = 0; i < detailRows.length; i++) { + const row = detailRows[i]; + const itemInfo = itemMap.get(row.item_number.trim())!; + const dbLevel = row.level - 1; + + while (levelStack.length > dbLevel) { + levelStack.pop(); + } + + const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null; + const parentKey = parentDetailId || "__root__"; + const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1; + seqCounterByParent.set(parentKey, currentSeq); + + const insertResult = await client.query( + `INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12) + RETURNING id`, + [ + newBomId, + versionId, + parentDetailId, + itemInfo.id, + String(dbLevel), + String(currentSeq), + String(row.quantity || 1), + row.unit || itemInfo.unit || null, + row.process_type || null, + row.remark || null, + userId, + companyCode, + ], + ); + + levelStack.push(insertResult.rows[0].id); + result.insertedCount++; + } + + // 5. 이력 기록 + await client.query( + `INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code) + VALUES ($1, 'excel_upload', $2, $3, $4)`, + [newBomId, `엑셀 업로드로 BOM 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode], + ); + + result.success = true; + logger.info("BOM 엑셀 업로드 - 새 BOM 생성 완료", { + newBomId, companyCode, + insertedCount: result.insertedCount, + }); + + return result; + }); +} + +/** + * BOM 엑셀 업로드 - 기존 BOM에 새 버전 생성 + * + * 엑셀에 레벨 0 행이 있으면 건너뛰고 (마스터는 이미 존재) + * 레벨 1 이상만 bom_detail로 INSERT, 새 bom_version에 연결 + */ +export async function createBomVersionFromExcel( + bomId: string, + companyCode: string, + userId: string, + rows: BomExcelRow[], + versionName?: string, +): Promise { + const result: BomExcelUploadResult = { + success: false, + insertedCount: 0, + skippedCount: 0, + errors: [], + unmatchedItems: [], + }; + + if (!rows || rows.length === 0) { + result.errors.push("업로드할 데이터가 없습니다"); + return result; + } + + const detailRows = rows.filter(r => r.level > 0); + result.skippedCount = rows.length - detailRows.length; + + if (detailRows.length === 0) { + result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)"); + return result; + } + + // 레벨 유효성 검사 + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (row.level < 0) { + result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`); + } + if (i > 0 && row.level > rows[i - 1].level + 1) { + result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다`); + } + if (row.level > 0 && !row.item_number?.trim()) { + result.errors.push(`${i + 1}행: 품번은 필수입니다`); + } + } + + if (result.errors.length > 0) { + return result; + } + + return transaction(async (client) => { + // 1. BOM 존재 확인 + const bomRow = await client.query( + `SELECT id, version FROM bom WHERE id = $1 AND company_code = $2`, + [bomId, companyCode], + ); + if (bomRow.rows.length === 0) { + result.errors.push("BOM을 찾을 수 없습니다"); + return result; + } + + // 2. 품번 → item_info 매핑 + const uniqueItemNumbers = [...new Set(detailRows.map(r => r.item_number.trim()))]; + const itemLookup = await client.query( + `SELECT id, item_number, item_name, unit FROM item_info + WHERE company_code = $1 AND item_number = ANY($2::text[])`, + [companyCode, uniqueItemNumbers], + ); + + const itemMap = new Map(); + for (const item of itemLookup.rows) { + itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit }); + } + + for (const num of uniqueItemNumbers) { + if (!itemMap.has(num)) { + result.unmatchedItems.push(num); + } + } + if (result.unmatchedItems.length > 0) { + result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`); + return result; + } + + // 3. 버전명 결정 (미입력 시 자동 채번) + let finalVersionName = versionName?.trim(); + if (!finalVersionName) { + const countResult = await client.query( + `SELECT COUNT(*)::int as cnt FROM bom_version WHERE bom_id = $1`, + [bomId], + ); + finalVersionName = `${(countResult.rows[0].cnt || 0) + 1}.0`; + } + + // 중복 체크 + const dupCheck = await client.query( + `SELECT id FROM bom_version WHERE bom_id = $1 AND version_name = $2`, + [bomId, finalVersionName], + ); + if (dupCheck.rows.length > 0) { + result.errors.push(`이미 존재하는 버전명입니다: ${finalVersionName}`); + return result; + } + + // 4. bom_version 생성 + const versionInsert = await client.query( + `INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code) + VALUES ($1, $2, 0, 'developing', $3, $4) RETURNING id`, + [bomId, finalVersionName, userId, companyCode], + ); + const newVersionId = versionInsert.rows[0].id; + + // 5. bom_detail INSERT + const levelStack: string[] = []; + const seqCounterByParent = new Map(); + + for (let i = 0; i < detailRows.length; i++) { + const row = detailRows[i]; + const itemInfo = itemMap.get(row.item_number.trim())!; + const dbLevel = row.level - 1; + + while (levelStack.length > dbLevel) { + levelStack.pop(); + } + + const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null; + const parentKey = parentDetailId || "__root__"; + const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1; + seqCounterByParent.set(parentKey, currentSeq); + + const insertResult = await client.query( + `INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12) + RETURNING id`, + [ + bomId, + newVersionId, + parentDetailId, + itemInfo.id, + String(dbLevel), + String(currentSeq), + String(row.quantity || 1), + row.unit || itemInfo.unit || null, + row.process_type || null, + row.remark || null, + userId, + companyCode, + ], + ); + + levelStack.push(insertResult.rows[0].id); + result.insertedCount++; + } + + // 6. BOM 헤더의 version과 current_version_id 갱신 + await client.query( + `UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`, + [finalVersionName, newVersionId, bomId], + ); + + // 7. 이력 기록 + await client.query( + `INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code) + VALUES ($1, 'excel_upload', $2, $3, $4)`, + [bomId, `엑셀 업로드로 새 버전 ${finalVersionName} 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode], + ); + + result.success = true; + result.createdBomId = bomId; + logger.info("BOM 엑셀 업로드 - 새 버전 생성 완료", { + bomId, companyCode, versionName: finalVersionName, + insertedCount: result.insertedCount, + }); + + return result; + }); +} + +/** + * BOM 엑셀 다운로드용 데이터 조회 + * + * 화면과 동일한 레벨 체계로 출력: + * 레벨 0 = BOM 헤더 (최상위 품목) + * 레벨 1 = 직접 자품목 (DB level=0) + * 레벨 N = DB level N-1 + * + * DFS로 순회하여 부모-자식 순서 보장 + */ +export async function downloadBomExcelData( + bomId: string, + companyCode: string, +): Promise[]> { + // BOM 헤더 정보 조회 (최상위 품목) + const bomHeader = await queryOne>( + `SELECT b.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit + FROM bom b + LEFT JOIN item_info ii ON b.item_id = ii.id + WHERE b.id = $1 AND b.company_code = $2`, + [bomId, companyCode], + ); + + if (!bomHeader) return []; + + const flatList: Record[] = []; + + // 레벨 0: BOM 헤더 (최상위 품목) + flatList.push({ + level: 0, + item_number: bomHeader.item_number || "", + item_name: bomHeader.item_name || "", + quantity: bomHeader.base_qty || "1", + unit: bomHeader.item_unit || bomHeader.unit || "", + process_type: "", + remark: bomHeader.remark || "", + _is_header: true, + }); + + // 하위 품목 조회 + const versionId = bomHeader.current_version_id; + const whereVersion = versionId ? `AND bd.version_id = $3` : `AND bd.version_id IS NULL`; + const params = versionId ? [bomId, companyCode, versionId] : [bomId, companyCode]; + + const details = await query( + `SELECT bd.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit, ii.size, ii.material + FROM bom_detail bd + LEFT JOIN item_info ii ON bd.child_item_id = ii.id + WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion} + ORDER BY bd.parent_detail_id NULLS FIRST, bd.seq_no::int`, + params, + ); + + // 부모 ID별 자식 목록으로 맵 구성 + const childrenMap = new Map(); + const roots: any[] = []; + for (const d of details) { + if (!d.parent_detail_id) { + roots.push(d); + } else { + if (!childrenMap.has(d.parent_detail_id)) childrenMap.set(d.parent_detail_id, []); + childrenMap.get(d.parent_detail_id)!.push(d); + } + } + + // DFS: depth로 정확한 레벨 계산 (DB level 무시, 실제 트리 깊이 사용) + const dfs = (nodes: any[], depth: number) => { + for (const node of nodes) { + flatList.push({ + level: depth, + item_number: node.item_number || "", + item_name: node.item_name || "", + quantity: node.quantity || "1", + unit: node.unit || node.item_unit || "", + process_type: node.process_type || "", + remark: node.remark || "", + }); + const children = childrenMap.get(node.id) || []; + if (children.length > 0) { + dfs(children, depth + 1); + } + } + }; + + // 루트 노드들은 레벨 1 (BOM 헤더가 0이므로) + dfs(roots, 1); + + return flatList; +} + /** * 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제 */ diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index a8765d18..2888a1f3 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -14,6 +14,35 @@ interface NumberingRulePart { autoConfig?: any; manualConfig?: any; generatedValue?: string; + separatorAfter?: string; +} + +/** + * 파트 배열에서 autoConfig.separatorAfter를 파트 레벨로 추출 + */ +function extractSeparatorAfterFromParts(parts: any[]): any[] { + return parts.map((part) => { + if (part.autoConfig?.separatorAfter !== undefined) { + part.separatorAfter = part.autoConfig.separatorAfter; + } + return part; + }); +} + +/** + * 파트별 개별 구분자를 사용하여 코드 결합 + * 마지막 파트의 separatorAfter는 무시됨 + */ +function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string { + let result = ""; + partValues.forEach((val, idx) => { + result += val; + if (idx < partValues.length - 1) { + const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator; + result += sep; + } + }); + return result; } interface NumberingRuleConfig { @@ -141,7 +170,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { @@ -274,7 +303,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } return result.rows; @@ -381,7 +410,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("✅ 규칙 파트 조회 성공", { ruleId: rule.ruleId, @@ -517,7 +546,7 @@ class NumberingRuleService { companyCode === "*" ? rule.companyCode : companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, { @@ -633,7 +662,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); return rule; } @@ -708,17 +737,25 @@ class NumberingRuleService { manual_config AS "manualConfig" `; + // auto_config에 separatorAfter 포함 + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + const partResult = await client.query(insertPartQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); - parts.push(partResult.rows[0]); + const savedPart = partResult.rows[0]; + // autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동 + if (savedPart.autoConfig?.separatorAfter !== undefined) { + savedPart.separatorAfter = savedPart.autoConfig.separatorAfter; + } + parts.push(savedPart); } await client.query("COMMIT"); @@ -820,17 +857,23 @@ class NumberingRuleService { manual_config AS "manualConfig" `; + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + const partResult = await client.query(insertPartQuery, [ ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); - parts.push(partResult.rows[0]); + const savedPart = partResult.rows[0]; + if (savedPart.autoConfig?.separatorAfter !== undefined) { + savedPart.separatorAfter = savedPart.autoConfig.separatorAfter; + } + parts.push(savedPart); } } @@ -1053,7 +1096,8 @@ class NumberingRuleService { } })); - const previewCode = parts.join(rule.separator || ""); + const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order); + const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || ""); logger.info("코드 미리보기 생성", { ruleId, previewCode, @@ -1164,8 +1208,8 @@ class NumberingRuleService { } })); - const separator = rule.separator || ""; - const previewTemplate = previewParts.join(separator); + const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order); + const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || ""); // 사용자 입력 코드에서 수동 입력 부분 추출 // 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출 @@ -1382,7 +1426,8 @@ class NumberingRuleService { } })); - const allocatedCode = parts.join(rule.separator || ""); + const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order); + const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || ""); // 순번이 있는 경우에만 증가 const hasSequence = rule.parts.some( @@ -1541,7 +1586,7 @@ class NumberingRuleService { rule.ruleId, companyCode === "*" ? rule.companyCode : companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info("[테스트] 채번 규칙 목록 조회 완료", { @@ -1634,7 +1679,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { ruleId: rule.ruleId, @@ -1754,12 +1799,14 @@ class NumberingRuleService { auto_config, manual_config, company_code, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) `; + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + await client.query(partInsertQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); @@ -1914,7 +1961,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("카테고리 조건 매칭 채번 규칙 찾음", { ruleId: rule.ruleId, @@ -1973,7 +2020,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", { ruleId: rule.ruleId, @@ -2056,7 +2103,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } return result.rows; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 252c5a89..791940ec 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -199,7 +199,15 @@ export class TableManagementService { cl.input_type as "cl_input_type", COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings", COALESCE(ttc.description, cl.description, '') as "description", - c.is_nullable as "isNullable", + CASE + WHEN COALESCE(ttc.is_nullable, cl.is_nullable) IS NOT NULL + THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END + ELSE c.is_nullable + END as "isNullable", + CASE + WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES' + ELSE 'NO' + END as "isUnique", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -241,7 +249,15 @@ export class TableManagementService { COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.detail_settings::text, '') as "detailSettings", COALESCE(cl.description, '') as "description", - c.is_nullable as "isNullable", + CASE + WHEN cl.is_nullable IS NOT NULL + THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END + ELSE c.is_nullable + END as "isNullable", + CASE + WHEN cl.is_unique = 'Y' THEN 'YES' + ELSE 'NO' + END as "isUnique", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -502,8 +518,8 @@ export class TableManagementService { table_name, column_name, column_label, input_type, detail_settings, code_category, code_value, reference_table, reference_column, display_column, display_order, is_visible, is_nullable, - company_code, created_date, updated_date - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW()) + company_code, category_ref, created_date, updated_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, $14, NOW(), NOW()) ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label), @@ -516,6 +532,7 @@ export class TableManagementService { display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column), display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order), is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible), + category_ref = EXCLUDED.category_ref, updated_date = NOW()`, [ tableName, @@ -531,6 +548,7 @@ export class TableManagementService { settings.displayOrder || 0, settings.isVisible !== undefined ? settings.isVisible : true, companyCode, + settings.categoryRef || null, ] ); @@ -1599,7 +1617,8 @@ export class TableManagementService { tableName, columnName, actualValue, - paramIndex + paramIndex, + operator ); case "entity": @@ -1612,7 +1631,14 @@ export class TableManagementService { ); default: - // 기본 문자열 검색 (actualValue 사용) + // operator에 따라 정확 일치 또는 부분 일치 검색 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(actualValue)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${actualValue}%`], @@ -1626,10 +1652,19 @@ export class TableManagementService { ); // 오류 시 기본 검색으로 폴백 let fallbackValue = value; + let fallbackOperator = "contains"; if (typeof value === "object" && value !== null && "value" in value) { fallbackValue = value.value; + fallbackOperator = value.operator || "contains"; } + if (fallbackOperator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(fallbackValue)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${fallbackValue}%`], @@ -1776,7 +1811,8 @@ export class TableManagementService { tableName: string, columnName: string, value: any, - paramIndex: number + paramIndex: number, + operator: string = "contains" ): Promise<{ whereClause: string; values: any[]; @@ -1786,7 +1822,14 @@ export class TableManagementService { const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName); if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) { - // 코드 타입이 아니면 기본 검색 + // 코드 타입이 아니면 operator에 따라 검색 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(value)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${value}%`], @@ -1794,6 +1837,15 @@ export class TableManagementService { }; } + // select 필터(equals)인 경우 정확한 코드값 매칭만 수행 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(value)], + paramCount: 1, + }; + } + if (typeof value === "string" && value.trim() !== "") { // 코드값 또는 코드명으로 검색 return { @@ -2431,6 +2483,154 @@ export class TableManagementService { return value; } + /** + * 회사별 NOT NULL 소프트 제약조건 검증 + * table_type_columns.is_nullable = 'N'인 컬럼에 NULL/빈값이 들어오면 위반 목록을 반환한다. + */ + async validateNotNullConstraints( + tableName: string, + data: Record, + companyCode: string + ): Promise { + try { + // 회사별 설정 우선, 없으면 공통(*) 설정 사용 + const notNullColumns = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_nullable = 'N' + AND ttc.company_code = $2`, + [tableName, companyCode] + ); + + // 회사별 설정이 없으면 공통 설정 확인 + if (notNullColumns.length === 0 && companyCode !== "*") { + const globalNotNull = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_nullable = 'N' + AND ttc.company_code = '*' + AND NOT EXISTS ( + SELECT 1 FROM table_type_columns ttc2 + WHERE ttc2.table_name = ttc.table_name + AND ttc2.column_name = ttc.column_name + AND ttc2.company_code = $2 + )`, + [tableName, companyCode] + ); + notNullColumns.push(...globalNotNull); + } + + if (notNullColumns.length === 0) return []; + + const violations: string[] = []; + for (const col of notNullColumns) { + const value = data[col.column_name]; + // NULL, undefined, 빈 문자열을 NOT NULL 위반으로 처리 + if (value === null || value === undefined || value === "") { + violations.push(col.column_label); + } + } + + return violations; + } catch (error) { + logger.error(`NOT NULL 검증 오류: ${tableName}`, error); + return []; + } + } + + /** + * 회사별 UNIQUE 소프트 제약조건 검증 + * table_type_columns.is_unique = 'Y'인 컬럼에 중복 값이 들어오면 위반 목록을 반환한다. + * @param excludeId 수정 시 자기 자신은 제외 + */ + async validateUniqueConstraints( + tableName: string, + data: Record, + companyCode: string, + excludeId?: string + ): Promise { + try { + // 회사별 설정 우선, 없으면 공통(*) 설정 사용 + let uniqueColumns = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_unique = 'Y' + AND ttc.company_code = $2`, + [tableName, companyCode] + ); + + // 회사별 설정이 없으면 공통 설정 확인 + if (uniqueColumns.length === 0 && companyCode !== "*") { + const globalUnique = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_unique = 'Y' + AND ttc.company_code = '*' + AND NOT EXISTS ( + SELECT 1 FROM table_type_columns ttc2 + WHERE ttc2.table_name = ttc.table_name + AND ttc2.column_name = ttc.column_name + AND ttc2.company_code = $2 + )`, + [tableName, companyCode] + ); + uniqueColumns = globalUnique; + } + + if (uniqueColumns.length === 0) return []; + + const violations: string[] = []; + for (const col of uniqueColumns) { + const value = data[col.column_name]; + if (value === null || value === undefined || value === "") continue; + + // 해당 회사 내에서 같은 값이 이미 존재하는지 확인 + const hasCompanyCode = await query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + let dupQuery: string; + let dupParams: any[]; + + if (hasCompanyCode.length > 0 && companyCode !== "*") { + dupQuery = excludeId + ? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1` + : `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`; + dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode]; + } else { + dupQuery = excludeId + ? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1` + : `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`; + dupParams = excludeId ? [value, excludeId] : [value]; + } + + const dupResult = await query(dupQuery, dupParams); + if (dupResult.length > 0) { + violations.push(`${col.column_label} (${value})`); + } + } + + return violations; + } catch (error) { + logger.error(`UNIQUE 검증 오류: ${tableName}`, error); + return []; + } + } + /** * 테이블에 데이터 추가 * @returns 무시된 컬럼 정보 (디버깅용) @@ -4355,7 +4555,8 @@ export class TableManagementService { END as "detailSettings", ttc.is_nullable as "isNullable", ic.data_type as "dataType", - ttc.company_code as "companyCode" + ttc.company_code as "companyCode", + ttc.category_ref as "categoryRef" FROM table_type_columns ttc LEFT JOIN information_schema.columns ic ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name @@ -4432,20 +4633,24 @@ export class TableManagementService { } const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => { - const baseInfo = { + const baseInfo: any = { tableName: tableName, columnName: col.columnName, displayName: col.displayName, dataType: col.dataType || "varchar", inputType: col.inputType, detailSettings: col.detailSettings, - description: "", // 필수 필드 추가 - isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환 + description: "", + isNullable: col.isNullable === "Y" ? "Y" : "N", isPrimaryKey: false, displayOrder: 0, isVisible: true, }; + if (col.categoryRef) { + baseInfo.categoryRef = col.categoryRef; + } + // 카테고리 타입인 경우 categoryMenus 추가 if ( col.inputType === "category" && diff --git a/backend-node/src/types/tableManagement.ts b/backend-node/src/types/tableManagement.ts index 8c786063..977031b8 100644 --- a/backend-node/src/types/tableManagement.ts +++ b/backend-node/src/types/tableManagement.ts @@ -44,6 +44,7 @@ export interface ColumnSettings { displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 displayOrder?: number; // 표시 순서 isVisible?: boolean; // 표시 여부 + categoryRef?: string | null; // 카테고리 참조 } export interface TableLabels { 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/docker/dev/docker-compose.frontend.mac.yml b/docker/dev/docker-compose.frontend.mac.yml index 6428d481..eda932da 100644 --- a/docker/dev/docker-compose.frontend.mac.yml +++ b/docker/dev/docker-compose.frontend.mac.yml @@ -9,6 +9,7 @@ services: - "9771:3000" environment: - NEXT_PUBLIC_API_URL=http://localhost:8080/api + - SERVER_API_URL=http://pms-backend-mac:8080 - NODE_OPTIONS=--max-old-space-size=8192 - NEXT_TELEMETRY_DISABLED=1 volumes: 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/docs/plan-bom-excel-upload.md b/docs/plan-bom-excel-upload.md new file mode 100644 index 00000000..d4c91afd --- /dev/null +++ b/docs/plan-bom-excel-upload.md @@ -0,0 +1,78 @@ +# BOM 엑셀 업로드 기능 개발 계획 + +## 개요 +탑씰(COMPANY_7) BOM관리 화면(screen_id=4168)에 엑셀 업로드 기능을 추가한다. +BOM은 트리 구조(parent_detail_id 자기참조)이므로 범용 엑셀 업로드를 사용할 수 없고, +BOM 전용 엑셀 업로드 컴포넌트를 개발한다. + +## 핵심 구조 + +### DB 테이블 +- `bom` (마스터): id(UUID), item_id(→item_info), version, current_version_id +- `bom_detail` (디테일-트리): id(UUID), bom_id(FK), parent_detail_id(자기참조), child_item_id(→item_info), level, seq_no, quantity, unit, loss_rate, process_type, version_id +- `item_info`: id, item_number(품번), item_name(품명), division(구분), unit, size, material + +### 엑셀 포맷 설계 (화면과 동일한 레벨 체계) +엑셀 파일은 다음 컬럼으로 구성: + +| 레벨 | 품번 | 품명 | 소요량 | 단위 | 로스율(%) | 공정구분 | 비고 | +|------|------|------|--------|------|-----------|----------|------| +| 0 | PROD-001 | 완제품A | 1 | EA | 0 | | ← BOM 헤더 (건너뜀) | +| 1 | P-001 | 부품A | 2 | EA | 0 | | ← 직접 자품목 | +| 2 | P-002 | 부품B | 3 | EA | 5 | 가공 | ← P-001의 하위 | +| 1 | P-003 | 부품C | 1 | KG | 0 | | ← 직접 자품목 | +| 2 | P-004 | 부품D | 4 | EA | 0 | 조립 | ← P-003의 하위 | +| 1 | P-005 | 부품E | 1 | EA | 0 | | ← 직접 자품목 | + +- 레벨 0: BOM 헤더 (최상위 품목) → 업로드 시 건너뜀 (이미 존재) +- 레벨 1: 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0) +- 레벨 2: 자품목의 하위 → bom_detail (parent_detail_id=부모ID, DB level=1) +- 레벨 N: → bom_detail (DB level=N-1) +- 품번으로 item_info를 조회하여 child_item_id 자동 매핑 + +### 트리 변환 로직 (레벨 1 이상만 처리) +엑셀 행을 순서대로 순회하면서 (레벨 0 건너뜀): +1. 각 행의 엑셀 레벨에서 -1하여 DB 레벨 계산 +2. 스택으로 부모-자식 관계 추적 + +``` +행1(레벨0) → BOM 헤더, 건너뜀 +행2(레벨1) → DB level=0, 스택: [행2] → parent_detail_id = null +행3(레벨2) → DB level=1, 스택: [행2, 행3] → parent_detail_id = 행2.id +행4(레벨1) → DB level=0, 스택: [행4] → parent_detail_id = null +행5(레벨2) → DB level=1, 스택: [행4, 행5] → parent_detail_id = 행4.id +행6(레벨1) → DB level=0, 스택: [행6] → parent_detail_id = null +``` + +## 테스트 계획 + +### 1단계: 백엔드 API +- [x] 테스트 1: 품번으로 item_info 일괄 조회 (존재하는 품번) +- [x] 테스트 2: 존재하지 않는 품번 에러 처리 +- [x] 테스트 3: 플랫 데이터 → 트리 구조 변환 (parent_detail_id 계산) +- [x] 테스트 4: bom_detail INSERT (version_id 포함) +- [x] 테스트 5: 기존 디테일 처리 (추가 모드 vs 전체교체 모드) + +### 2단계: 프론트엔드 모달 +- [x] 테스트 6: 엑셀 파일 파싱 및 미리보기 +- [x] 테스트 7: 품번 매핑 결과 표시 (성공/실패) +- [x] 테스트 8: 업로드 실행 및 결과 표시 + +### 3단계: 통합 +- [x] 테스트 9: BomTreeComponent에 엑셀 업로드 버튼 추가 +- [x] 테스트 10: 업로드 후 트리 자동 새로고침 + +## 구현 파일 목록 + +### 백엔드 +1. `backend-node/src/services/bomService.ts` - `uploadBomExcel()` 함수 추가 +2. `backend-node/src/controllers/bomController.ts` - `uploadBomExcel` 핸들러 추가 +3. `backend-node/src/routes/bomRoutes.ts` - `POST /:bomId/excel-upload` 라우트 추가 + +### 프론트엔드 +4. `frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx` - 전용 모달 신규 +5. `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` - 업로드 버튼 추가 + +## 진행 상태 +- 완료된 테스트는 [x]로 표시 +- 현재 진행 중인 테스트는 [진행중]으로 표시 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/docs/screen-implementation-guide/01_master-data/item-info.md b/docs/screen-implementation-guide/01_master-data/item-info.md index b0ddd9e0..223eed42 100644 --- a/docs/screen-implementation-guide/01_master-data/item-info.md +++ b/docs/screen-implementation-guide/01_master-data/item-info.md @@ -8,7 +8,7 @@ ## ⚠️ 문서 사용 안내 -> **이 문서는 "품목정보" 화면의 구현 예시입니다.** + > > ### 📌 중요: JSON 데이터는 참고용입니다! > diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index cf89df73..d5c41e6a 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -63,6 +63,7 @@ interface ColumnTypeInfo { detailSettings: string; description: string; isNullable: string; + isUnique: string; defaultValue?: string; maxLength?: number; numericPrecision?: number; @@ -72,9 +73,10 @@ interface ColumnTypeInfo { referenceTable?: string; referenceColumn?: string; displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 - categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열 - hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할 - numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID + categoryMenus?: number[]; + hierarchyRole?: "large" | "medium" | "small"; + numberingRuleId?: string; + categoryRef?: string | null; } interface SecondLevelMenu { @@ -382,10 +384,12 @@ export default function TableManagementPage() { return { ...col, - inputType: col.inputType || "text", // 기본값: text - numberingRuleId, // 🆕 채번규칙 ID - categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보 - hierarchyRole, // 계층구조 역할 + inputType: col.inputType || "text", + isUnique: col.isUnique || "NO", + numberingRuleId, + categoryMenus: col.categoryMenus || [], + hierarchyRole, + categoryRef: col.categoryRef || null, }; }); @@ -668,15 +672,16 @@ export default function TableManagementPage() { } const columnSetting = { - columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) - columnLabel: column.displayName, // 사용자가 입력한 표시명 + columnName: column.columnName, + columnLabel: column.displayName, inputType: column.inputType || "text", detailSettings: finalDetailSettings, codeCategory: column.codeCategory || "", codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", referenceColumn: column.referenceColumn || "", - displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명 + displayColumn: column.displayColumn || "", + categoryRef: column.categoryRef || null, }; // console.log("저장할 컬럼 설정:", columnSetting); @@ -703,9 +708,9 @@ export default function TableManagementPage() { length: column.categoryMenus?.length || 0, }); - if (column.inputType === "category") { - // 1. 먼저 기존 매핑 모두 삭제 - console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", { + if (column.inputType === "category" && !column.categoryRef) { + // 참조가 아닌 자체 카테고리만 메뉴 매핑 처리 + console.log("기존 카테고리 메뉴 매핑 삭제 시작:", { tableName: selectedTable, columnName: column.columnName, }); @@ -864,8 +869,8 @@ export default function TableManagementPage() { } return { - columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) - columnLabel: column.displayName, // 사용자가 입력한 표시명 + columnName: column.columnName, + columnLabel: column.displayName, inputType: column.inputType || "text", detailSettings: finalDetailSettings, description: column.description || "", @@ -873,7 +878,8 @@ export default function TableManagementPage() { codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", referenceColumn: column.referenceColumn || "", - displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명 + displayColumn: column.displayColumn || "", + categoryRef: column.categoryRef || null, }; }); @@ -886,8 +892,8 @@ export default function TableManagementPage() { ); if (response.data.success) { - // 🆕 Category 타입 컬럼들의 메뉴 매핑 처리 - const categoryColumns = columns.filter((col) => col.inputType === "category"); + // 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외) + const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef); console.log("📥 전체 저장: 카테고리 컬럼 확인", { totalColumns: columns.length, @@ -1091,9 +1097,9 @@ export default function TableManagementPage() { } }; - // 인덱스 토글 핸들러 + // 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨) const handleIndexToggle = useCallback( - async (columnName: string, indexType: "index" | "unique", checked: boolean) => { + async (columnName: string, indexType: "index", checked: boolean) => { if (!selectedTable) return; const action = checked ? "create" : "drop"; try { @@ -1122,14 +1128,41 @@ export default function TableManagementPage() { const hasIndex = constraints.indexes.some( (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, ); - const hasUnique = constraints.indexes.some( - (idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, - ); - return { isPk, hasIndex, hasUnique }; + return { isPk, hasIndex }; }, [constraints], ); + // UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴) + const handleUniqueToggle = useCallback( + async (columnName: string, currentIsUnique: string) => { + if (!selectedTable) return; + const isCurrentlyUnique = currentIsUnique === "YES"; + const newUnique = !isCurrentlyUnique; + try { + const response = await apiClient.put( + `/table-management/tables/${selectedTable}/columns/${columnName}/unique`, + { unique: newUnique }, + ); + if (response.data.success) { + toast.success(response.data.message); + setColumns((prev) => + prev.map((col) => + col.columnName === columnName + ? { ...col, isUnique: newUnique ? "YES" : "NO" } + : col, + ), + ); + } else { + toast.error(response.data.message || "UNIQUE 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다."); + } + }, + [selectedTable], + ); + // NOT NULL 토글 핸들러 const handleNullableToggle = useCallback( async (columnName: string, currentIsNullable: string) => { @@ -1662,7 +1695,30 @@ export default function TableManagementPage() { )} )} - {/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */} + {/* 카테고리 타입: 참조 설정 */} + {column.inputType === "category" && ( +
+ + { + const val = e.target.value || null; + setColumns((prev) => + prev.map((c) => + c.columnName === column.columnName + ? { ...c, categoryRef: val } + : c + ) + ); + }} + placeholder="테이블명.컬럼명" + className="h-8 text-xs" + /> +

+ 다른 테이블의 카테고리 값 참조 시 입력 +

+
+ )} {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {column.inputType === "entity" && ( <> @@ -2029,12 +2085,12 @@ export default function TableManagementPage() { aria-label={`${column.columnName} 인덱스 설정`} /> - {/* UQ 체크박스 */} + {/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
- handleIndexToggle(column.columnName, "unique", checked as boolean) + checked={column.isUnique === "YES"} + onCheckedChange={() => + handleUniqueToggle(column.columnName, column.isUnique) } aria-label={`${column.columnName} 유니크 설정`} /> diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 95305aaf..160883ad 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -87,10 +87,12 @@ function ScreenViewPage() { // 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이) const [conditionalContainerHeights, setConditionalContainerHeights] = useState>({}); - // 🆕 레이어 시스템 지원 + // 레이어 시스템 지원 const [conditionalLayers, setConditionalLayers] = useState([]); - // 🆕 조건부 영역(Zone) 목록 + // 조건부 영역(Zone) 목록 const [zones, setZones] = useState([]); + // 데이터 전달에 의해 강제 활성화된 레이어 ID 목록 + const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState([]); // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); @@ -378,11 +380,51 @@ function ScreenViewPage() { } }); - return newActiveIds; - }, [formData, conditionalLayers, layout]); + // 강제 활성화된 레이어 ID 병합 + for (const forcedId of forceActivatedLayerIds) { + if (!newActiveIds.includes(forcedId)) { + newActiveIds.push(forcedId); + } + } - // 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼) - // 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움 + return newActiveIds; + }, [formData, conditionalLayers, layout, forceActivatedLayerIds]); + + // 데이터 전달에 의한 레이어 강제 활성화 이벤트 리스너 + useEffect(() => { + const handleActivateLayer = (e: Event) => { + const { componentId, targetLayerId } = (e as CustomEvent).detail || {}; + if (!componentId && !targetLayerId) return; + + // targetLayerId가 직접 지정된 경우 + if (targetLayerId) { + setForceActivatedLayerIds((prev) => + prev.includes(targetLayerId) ? prev : [...prev, targetLayerId], + ); + console.log(`🔓 [레이어 강제 활성화] layerId: ${targetLayerId}`); + return; + } + + // componentId로 해당 컴포넌트가 속한 레이어를 찾아 활성화 + for (const layer of conditionalLayers) { + const found = layer.components.some((comp) => comp.id === componentId); + if (found) { + setForceActivatedLayerIds((prev) => + prev.includes(layer.id) ? prev : [...prev, layer.id], + ); + console.log(`🔓 [레이어 강제 활성화] componentId: ${componentId} → layerId: ${layer.id}`); + return; + } + } + }; + + window.addEventListener("activateLayerForComponent", handleActivateLayer); + return () => { + window.removeEventListener("activateLayerForComponent", handleActivateLayer); + }; + }, [conditionalLayers]); + + // 메인 테이블 데이터 자동 로드 (단일 레코드 폼) useEffect(() => { const loadMainTableData = async () => { if (!screen || !layout || !layout.components || !companyCode) { diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index f578b30e..861795b5 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -24,7 +24,9 @@ import { GRID_BREAKPOINTS, detectGridMode, } from "@/components/pop/designer/types/pop-layout"; -import PopRenderer from "@/components/pop/designer/renderers/PopRenderer"; +// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import) +import "@/lib/registry/pop-components"; +import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals"; import { useResponsiveModeWithOverride, type DeviceType, @@ -144,6 +146,28 @@ function PopScreenViewPage() { } }, [screenId]); + // 뷰어 모드에서도 컴포넌트 크기 변경 지원 (더보기 등) + const handleRequestResize = React.useCallback((componentId: string, newRowSpan: number, newColSpan?: number) => { + setLayout((prev) => { + const comp = prev.components[componentId]; + if (!comp) return prev; + return { + ...prev, + components: { + ...prev.components, + [componentId]: { + ...comp, + position: { + ...comp.position, + rowSpan: newRowSpan, + ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), + }, + }, + }, + }; + }); + }, []); + const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"]; const hasComponents = Object.keys(layout.components).length > 0; @@ -180,7 +204,7 @@ function PopScreenViewPage() { -
+
{/* 상단 툴바 (프리뷰 모드에서만) */} {isPreviewMode && (
@@ -261,7 +285,7 @@ function PopScreenViewPage() { )} {/* POP 화면 컨텐츠 */} -
+
{/* 현재 모드 표시 (일반 모드) */} {!isPreviewMode && (
@@ -270,7 +294,7 @@ function PopScreenViewPage() { )}
); })()} diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 4797a34a..81b5ed61 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -942,17 +942,22 @@ export const ExcelUploadModal: React.FC = ({ continue; } - // 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용) + // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용 if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { - try { - const { apiClient } = await import("@/lib/api/client"); - const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`); - const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code; - if (numberingResponse.data?.success && generatedCode) { - dataToSave[numberingInfo.columnName] = generatedCode; + const existingValue = dataToSave[numberingInfo.columnName]; + const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== ""; + + if (!hasExcelValue) { + try { + const { apiClient } = await import("@/lib/api/client"); + const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`); + const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code; + if (numberingResponse.data?.success && generatedCode) { + dataToSave[numberingInfo.columnName] = generatedCode; + } + } catch (numError) { + console.error("채번 오류:", numError); } - } catch (numError) { - console.error("채번 오류:", numError); } } diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 4c4614c9..854b1159 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -25,6 +25,7 @@ import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; interface ScreenModalState { isOpen: boolean; @@ -178,10 +179,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 +363,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); // 신규 등록 모드 @@ -1016,6 +1026,10 @@ export const ScreenModal: React.FC = ({ className }) => {
) : screenData ? ( +
= ({ 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; }); }} @@ -1245,6 +1259,7 @@ export const ScreenModal: React.FC = ({ className }) => {
+
) : (

화면 데이터가 없습니다.

@@ -1273,7 +1288,7 @@ export const ScreenModal: React.FC = ({ className }) => { {/* 모달 닫기 확인 다이얼로그 */} - + 화면을 닫으시겠습니까? diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index d1444d4e..e9731017 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -25,7 +25,7 @@ export const NumberingRuleCard: React.FC = ({ isPreview = false, }) => { return ( - +
diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 9320f00e..8b521fe0 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -62,9 +62,9 @@ export const NumberingRuleDesigner: React.FC = ({ const [editingLeftTitle, setEditingLeftTitle] = useState(false); const [editingRightTitle, setEditingRightTitle] = useState(false); - // 구분자 관련 상태 - const [separatorType, setSeparatorType] = useState("-"); - const [customSeparator, setCustomSeparator] = useState(""); + // 구분자 관련 상태 (개별 파트 사이 구분자) + const [separatorTypes, setSeparatorTypes] = useState>({}); + const [customSeparators, setCustomSeparators] = useState>({}); // 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회 interface CategoryOption { @@ -192,48 +192,68 @@ export const NumberingRuleDesigner: React.FC = ({ } }, [currentRule, onChange]); - // currentRule이 변경될 때 구분자 상태 동기화 + // currentRule이 변경될 때 파트별 구분자 상태 동기화 useEffect(() => { - if (currentRule) { - const sep = currentRule.separator ?? "-"; - // 빈 문자열이면 "none" - if (sep === "") { - setSeparatorType("none"); - setCustomSeparator(""); - return; - } - // 미리 정의된 구분자인지 확인 (none, custom 제외) - const predefinedOption = SEPARATOR_OPTIONS.find( - opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep - ); - if (predefinedOption) { - setSeparatorType(predefinedOption.value); - setCustomSeparator(""); - } else { - // 직접 입력된 구분자 - setSeparatorType("custom"); - setCustomSeparator(sep); - } + if (currentRule && currentRule.parts.length > 0) { + const newSepTypes: Record = {}; + const newCustomSeps: Record = {}; + + currentRule.parts.forEach((part) => { + const sep = part.separatorAfter ?? currentRule.separator ?? "-"; + if (sep === "") { + newSepTypes[part.order] = "none"; + newCustomSeps[part.order] = ""; + } else { + const predefinedOption = SEPARATOR_OPTIONS.find( + opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep + ); + if (predefinedOption) { + newSepTypes[part.order] = predefinedOption.value; + newCustomSeps[part.order] = ""; + } else { + newSepTypes[part.order] = "custom"; + newCustomSeps[part.order] = sep; + } + } + }); + + setSeparatorTypes(newSepTypes); + setCustomSeparators(newCustomSeps); } - }, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시) + }, [currentRule?.ruleId]); - // 구분자 변경 핸들러 - const handleSeparatorChange = useCallback((type: SeparatorType) => { - setSeparatorType(type); + // 개별 파트 구분자 변경 핸들러 + const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => { + setSeparatorTypes(prev => ({ ...prev, [partOrder]: type })); if (type !== "custom") { const option = SEPARATOR_OPTIONS.find(opt => opt.value === type); const newSeparator = option?.displayValue ?? ""; - setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null); - setCustomSeparator(""); + setCustomSeparators(prev => ({ ...prev, [partOrder]: "" })); + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => + part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part + ), + }; + }); } }, []); - // 직접 입력 구분자 변경 핸들러 - const handleCustomSeparatorChange = useCallback((value: string) => { - // 최대 2자 제한 + // 개별 파트 직접 입력 구분자 변경 핸들러 + const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => { const trimmedValue = value.slice(0, 2); - setCustomSeparator(trimmedValue); - setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null); + setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue })); + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => + part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part + ), + }; + }); }, []); const handleAddPart = useCallback(() => { @@ -250,6 +270,7 @@ export const NumberingRuleDesigner: React.FC = ({ partType: "text", generationMethod: "auto", autoConfig: { textValue: "CODE" }, + separatorAfter: "-", }; setCurrentRule((prev) => { @@ -257,6 +278,10 @@ export const NumberingRuleDesigner: React.FC = ({ return { ...prev, parts: [...prev.parts, newPart] }; }); + // 새 파트의 구분자 상태 초기화 + setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" })); + setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" })); + toast.success(`규칙 ${newPart.order}가 추가되었습니다`); }, [currentRule, maxRules]); @@ -573,42 +598,6 @@ export const NumberingRuleDesigner: React.FC = ({
- {/* 두 번째 줄: 구분자 설정 */} -
-
- - -
- {separatorType === "custom" && ( -
- - handleCustomSeparatorChange(e.target.value)} - className="h-9" - placeholder="최대 2자" - maxLength={2} - /> -
- )} -

- 규칙 사이에 들어갈 문자입니다 -

-
@@ -625,15 +614,48 @@ export const NumberingRuleDesigner: React.FC = ({

규칙을 추가하여 코드를 구성하세요

) : ( -
+
{currentRule.parts.map((part, index) => ( - handleUpdatePart(part.order, updates)} - onDelete={() => handleDeletePart(part.order)} - isPreview={isPreview} - /> + +
+ handleUpdatePart(part.order, updates)} + onDelete={() => handleDeletePart(part.order)} + isPreview={isPreview} + /> + {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} + {index < currentRule.parts.length - 1 && ( +
+ 뒤 구분자 + + {separatorTypes[part.order] === "custom" && ( + handlePartCustomSeparatorChange(part.order, e.target.value)} + className="h-6 w-14 text-center text-[10px]" + placeholder="2자" + maxLength={2} + /> + )} +
+ )} +
+
))}
)} diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx index a9179959..eff551a1 100644 --- a/frontend/components/numbering-rule/NumberingRulePreview.tsx +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -17,75 +17,71 @@ export const NumberingRulePreview: React.FC = ({ return "규칙을 추가해주세요"; } - const parts = config.parts - .sort((a, b) => a.order - b.order) - .map((part) => { - if (part.generationMethod === "manual") { - return part.manualConfig?.value || "XXX"; + const sortedParts = config.parts.sort((a, b) => a.order - b.order); + + const partValues = sortedParts.map((part) => { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || "XXX"; + } + + const autoConfig = part.autoConfig || {}; + + switch (part.partType) { + case "sequence": { + const length = autoConfig.sequenceLength || 3; + const startFrom = autoConfig.startFrom || 1; + return String(startFrom).padStart(length, "0"); } - - const autoConfig = part.autoConfig || {}; - - switch (part.partType) { - // 1. 순번 (자동 증가) - case "sequence": { - const length = autoConfig.sequenceLength || 3; - const startFrom = autoConfig.startFrom || 1; - return String(startFrom).padStart(length, "0"); - } - - // 2. 숫자 (고정 자릿수) - case "number": { - const length = autoConfig.numberLength || 4; - const value = autoConfig.numberValue || 0; - return String(value).padStart(length, "0"); - } - - // 3. 날짜 - case "date": { - const format = autoConfig.dateFormat || "YYYYMMDD"; - - // 컬럼 기준 생성인 경우 placeholder 표시 - if (autoConfig.useColumnValue && autoConfig.sourceColumnName) { - // 형식에 맞는 placeholder 반환 - switch (format) { - case "YYYY": return "[YYYY]"; - case "YY": return "[YY]"; - case "YYYYMM": return "[YYYYMM]"; - case "YYMM": return "[YYMM]"; - case "YYYYMMDD": return "[YYYYMMDD]"; - case "YYMMDD": return "[YYMMDD]"; - default: return "[DATE]"; - } - } - - // 현재 날짜 기준 생성 - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - + case "number": { + const length = autoConfig.numberLength || 4; + const value = autoConfig.numberValue || 0; + return String(value).padStart(length, "0"); + } + case "date": { + const format = autoConfig.dateFormat || "YYYYMMDD"; + if (autoConfig.useColumnValue && autoConfig.sourceColumnName) { switch (format) { - case "YYYY": return String(year); - case "YY": return String(year).slice(-2); - case "YYYYMM": return `${year}${month}`; - case "YYMM": return `${String(year).slice(-2)}${month}`; - case "YYYYMMDD": return `${year}${month}${day}`; - case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; - default: return `${year}${month}${day}`; + case "YYYY": return "[YYYY]"; + case "YY": return "[YY]"; + case "YYYYMM": return "[YYYYMM]"; + case "YYMM": return "[YYMM]"; + case "YYYYMMDD": return "[YYYYMMDD]"; + case "YYMMDD": return "[YYMMDD]"; + default: return "[DATE]"; } } - - // 4. 문자 - case "text": - return autoConfig.textValue || "TEXT"; - - default: - return "XXX"; + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + switch (format) { + case "YYYY": return String(year); + case "YY": return String(year).slice(-2); + case "YYYYMM": return `${year}${month}`; + case "YYMM": return `${String(year).slice(-2)}${month}`; + case "YYYYMMDD": return `${year}${month}${day}`; + case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; + default: return `${year}${month}${day}`; + } } - }); + case "text": + return autoConfig.textValue || "TEXT"; + default: + return "XXX"; + } + }); - return parts.join(config.separator || ""); + // 파트별 개별 구분자로 결합 + const globalSep = config.separator ?? "-"; + let result = ""; + partValues.forEach((val, idx) => { + result += val; + if (idx < partValues.length - 1) { + const sep = sortedParts[idx].separatorAfter ?? globalSep; + result += sep; + } + }); + return result; }, [config]); if (compact) { diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index 7753a992..2edefb3a 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -13,8 +13,12 @@ import { GAP_PRESETS, GRID_BREAKPOINTS, DEFAULT_COMPONENT_GRID_SIZE, + PopModalDefinition, + ModalSizePreset, + MODAL_SIZE_PRESETS, + resolveModalWidth, } from "./types/pop-layout"; -import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff } from "lucide-react"; +import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react"; import { useDrag } from "react-dnd"; import { Button } from "@/components/ui/button"; import { @@ -112,6 +116,16 @@ interface PopCanvasProps { onLockLayout?: () => void; onResetOverride?: (mode: GridMode) => void; onChangeGapPreset?: (preset: GapPreset) => void; + /** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */ + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; + /** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */ + previewPageIndex?: number; + /** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */ + activeCanvasId?: string; + /** 캔버스 전환 콜백 */ + onActiveCanvasChange?: (canvasId: string) => void; + /** 모달 정의 업데이트 콜백 */ + onUpdateModal?: (modalId: string, updates: Partial) => void; } // ======================================== @@ -135,7 +149,43 @@ export default function PopCanvas({ onLockLayout, onResetOverride, onChangeGapPreset, + onRequestResize, + previewPageIndex, + activeCanvasId = "main", + onActiveCanvasChange, + onUpdateModal, }: PopCanvasProps) { + // 모달 탭 데이터 + const modalTabs = useMemo(() => { + const tabs: { id: string; label: string }[] = [{ id: "main", label: "메인화면" }]; + if (layout.modals?.length) { + for (const modal of layout.modals) { + const numbering = modal.id.replace("modal-", ""); + tabs.push({ id: modal.id, label: `모달화면 ${numbering}` }); + } + } + return tabs; + }, [layout.modals]); + + // activeCanvasId에 따라 렌더링할 layout 분기 + const activeLayout = useMemo((): PopLayoutDataV5 => { + if (activeCanvasId === "main") return layout; + const modal = layout.modals?.find(m => m.id === activeCanvasId); + if (!modal) return layout; // fallback + return { + ...layout, + gridConfig: modal.gridConfig, + components: modal.components, + overrides: modal.overrides, + }; + }, [layout, activeCanvasId]); + + // 현재 활성 모달 정의 (모달 캔버스일 때만) + const activeModal = useMemo(() => { + if (activeCanvasId === "main") return null; + return layout.modals?.find(m => m.id === activeCanvasId) || null; + }, [layout.modals, activeCanvasId]); + // 줌 상태 const [canvasScale, setCanvasScale] = useState(0.8); @@ -162,12 +212,12 @@ export default function PopCanvas({ const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); - // 숨김 컴포넌트 ID 목록 - const hiddenComponentIds = layout.overrides?.[currentMode]?.hidden || []; + // 숨김 컴포넌트 ID 목록 (activeLayout 기반) + const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || []; // 동적 캔버스 높이 계산 (컴포넌트 배치 기반) const dynamicCanvasHeight = useMemo(() => { - const visibleComps = Object.values(layout.components).filter( + const visibleComps = Object.values(activeLayout.components).filter( comp => !hiddenComponentIds.includes(comp.id) ); @@ -186,7 +236,7 @@ export default function PopCanvas({ const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2; return Math.max(MIN_CANVAS_HEIGHT, height); - }, [layout.components, layout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]); + }, [activeLayout.components, activeLayout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]); // 그리드 라벨 계산 (동적 행 수) const gridLabels = useMemo(() => { @@ -300,7 +350,7 @@ export default function PopCanvas({ }; // 현재 모드에서의 유효 위치들로 중첩 검사 - const effectivePositions = getAllEffectivePositions(layout, currentMode); + const effectivePositions = getAllEffectivePositions(activeLayout, currentMode); const existingPositions = Array.from(effectivePositions.values()); const hasOverlap = existingPositions.some(pos => @@ -346,7 +396,7 @@ export default function PopCanvas({ const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean }; // 현재 모드에서의 유효 위치들 가져오기 - const effectivePositions = getAllEffectivePositions(layout, currentMode); + const effectivePositions = getAllEffectivePositions(activeLayout, currentMode); // 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기 // 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 @@ -398,42 +448,42 @@ export default function PopCanvas({ canDrop: monitor.canDrop(), }), }), - [onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding] + [onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, activeLayout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding] ); drop(canvasRef); - // 빈 상태 체크 - const isEmpty = Object.keys(layout.components).length === 0; + // 빈 상태 체크 (activeLayout 기반) + const isEmpty = Object.keys(activeLayout.components).length === 0; - // 숨김 처리된 컴포넌트 객체 목록 (hiddenComponentIds는 라인 166에서 정의됨) + // 숨김 처리된 컴포넌트 객체 목록 const hiddenComponents = useMemo(() => { return hiddenComponentIds - .map(id => layout.components[id]) + .map(id => activeLayout.components[id]) .filter(Boolean); - }, [hiddenComponentIds, layout.components]); + }, [hiddenComponentIds, activeLayout.components]); // 표시되는 컴포넌트 목록 (숨김 제외) const visibleComponents = useMemo(() => { - return Object.values(layout.components).filter( + return Object.values(activeLayout.components).filter( comp => !hiddenComponentIds.includes(comp.id) ); - }, [layout.components, hiddenComponentIds]); + }, [activeLayout.components, hiddenComponentIds]); // 검토 필요 컴포넌트 목록 const reviewComponents = useMemo(() => { return visibleComponents.filter(comp => { - const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id]; + const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id]; return needsReview(currentMode, hasOverride); }); - }, [visibleComponents, layout.overrides, currentMode]); + }, [visibleComponents, activeLayout.overrides, currentMode]); // 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때) const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0; // 12칸 모드가 아닐 때만 패널 표시 // 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시 - const hasGridComponents = Object.keys(layout.components).length > 0; + const hasGridComponents = Object.keys(activeLayout.components).length > 0; const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents); const showRightPanel = showReviewPanel || showHiddenPanel; @@ -573,6 +623,32 @@ export default function PopCanvas({
+ {/* 모달 탭 바 (모달이 1개 이상 있을 때만 표시) */} + {modalTabs.length > 1 && ( +
+ {modalTabs.map(tab => ( + + ))} +
+ )} + + {/* 모달 사이즈 설정 패널 (모달 캔버스 활성 시) */} + {activeModal && ( + onUpdateModal?.(activeModal.id, updates)} + /> + )} + {/* 캔버스 영역 */}
)}
@@ -969,3 +1047,278 @@ function HiddenItem({
); } + +// ======================================== +// 모달 사이즈 설정 패널 +// ======================================== + +const SIZE_PRESET_ORDER: ModalSizePreset[] = ["sm", "md", "lg", "xl", "full"]; + +const MODE_LABELS: { mode: GridMode; label: string; icon: typeof Smartphone; width: number }[] = [ + { mode: "mobile_portrait", label: "모바일 세로", icon: Smartphone, width: 375 }, + { mode: "mobile_landscape", label: "모바일 가로", icon: Smartphone, width: 667 }, + { mode: "tablet_portrait", label: "태블릿 세로", icon: Tablet, width: 768 }, + { mode: "tablet_landscape", label: "태블릿 가로", icon: Monitor, width: 1024 }, +]; + +function ModalSizeSettingsPanel({ + modal, + currentMode, + onUpdate, +}: { + modal: PopModalDefinition; + currentMode: GridMode; + onUpdate: (updates: Partial) => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const sizeConfig = modal.sizeConfig || { default: "md" }; + const usePerMode = !!sizeConfig.modeOverrides && Object.keys(sizeConfig.modeOverrides).length > 0; + + const currentModeInfo = MODE_LABELS.find(m => m.mode === currentMode)!; + const currentModeWidth = currentModeInfo.width; + const currentModalWidth = resolveModalWidth( + { default: sizeConfig.default, modeOverrides: sizeConfig.modeOverrides as Record | undefined }, + currentMode, + currentModeWidth, + ); + + const handleDefaultChange = (preset: ModalSizePreset) => { + onUpdate({ + sizeConfig: { + ...sizeConfig, + default: preset, + }, + }); + }; + + const handleTogglePerMode = () => { + if (usePerMode) { + onUpdate({ + sizeConfig: { + default: sizeConfig.default, + }, + }); + } else { + onUpdate({ + sizeConfig: { + ...sizeConfig, + modeOverrides: { + mobile_portrait: sizeConfig.default, + mobile_landscape: sizeConfig.default, + tablet_portrait: sizeConfig.default, + tablet_landscape: sizeConfig.default, + }, + }, + }); + } + }; + + const handleModeChange = (mode: GridMode, preset: ModalSizePreset) => { + onUpdate({ + sizeConfig: { + ...sizeConfig, + modeOverrides: { + ...sizeConfig.modeOverrides, + [mode]: preset, + }, + }, + }); + }; + + return ( +
+ {/* 헤더 (항상 표시) */} + + + {/* 펼침 영역 */} + {isExpanded && ( +
+ {/* 기본 사이즈 선택 */} +
+ 모달 사이즈 +
+ {SIZE_PRESET_ORDER.map(preset => { + const info = MODAL_SIZE_PRESETS[preset]; + return ( + + ); + })} +
+
+ + {/* 모드별 개별 설정 토글 */} +
+ 모드별 개별 사이즈 + +
+ + {/* 모드별 설정 */} + {usePerMode && ( +
+ {MODE_LABELS.map(({ mode, label, icon: Icon }) => { + const modePreset = sizeConfig.modeOverrides?.[mode] ?? sizeConfig.default; + return ( +
+
+ + {label} +
+
+ {SIZE_PRESET_ORDER.map(preset => ( + + ))} +
+
+ ); + })} +
+ )} + + {/* 캔버스 축소판 미리보기 */} + +
+ )} +
+ ); +} + +// ======================================== +// 모달 사이즈 썸네일 미리보기 (캔버스 축소판 + 모달 오버레이) +// ======================================== + +function ModalThumbnailPreview({ + sizeConfig, + currentMode, +}: { + sizeConfig: { default: ModalSizePreset; modeOverrides?: Partial> }; + currentMode: GridMode; +}) { + const PREVIEW_WIDTH = 260; + const ASPECT_RATIO = 0.65; + + const modeInfo = MODE_LABELS.find(m => m.mode === currentMode)!; + const modeWidth = modeInfo.width; + const modeHeight = modeWidth * ASPECT_RATIO; + + const scale = PREVIEW_WIDTH / modeWidth; + const previewHeight = Math.round(modeHeight * scale); + + const modalWidth = resolveModalWidth( + { default: sizeConfig.default, modeOverrides: sizeConfig.modeOverrides as Record | undefined }, + currentMode, + modeWidth, + ); + const scaledModalWidth = Math.min(Math.round(modalWidth * scale), PREVIEW_WIDTH); + const isFull = modalWidth >= modeWidth; + const scaledModalHeight = isFull ? previewHeight : Math.round(previewHeight * 0.75); + const Icon = modeInfo.icon; + + return ( +
+
+ 미리보기 +
+ + {modeInfo.label} +
+
+ +
+ {/* 반투명 배경 오버레이 (모달이 열렸을 때의 딤 효과) */} +
+ + {/* 모달 영역 (가운데 정렬, FULL이면 전체 채움) */} +
+
+ 모달 +
+
+ + {/* 하단 수치 표시 */} +
+ {isFull ? "FULL" : `${modalWidth}px`} / {modeWidth}px +
+
+
+ ); +} diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 8bcc8f3a..902eb9a9 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -28,11 +28,15 @@ import { createEmptyPopLayoutV5, isV5Layout, addComponentToV5Layout, + createComponentDefinitionV5, GRID_BREAKPOINTS, + PopModalDefinition, + PopDataConnection, } from "./types/pop-layout"; import { getAllEffectivePositions } from "./utils/gridUtils"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; +import { PopDesignerContext } from "./PopDesignerContext"; // ======================================== // Props @@ -51,6 +55,7 @@ export default function PopDesigner({ onBackToList, onScreenUpdate, }: PopDesignerProps) { + // ======================================== // 레이아웃 상태 // ======================================== @@ -69,13 +74,24 @@ export default function PopDesigner({ // 선택 상태 const [selectedComponentId, setSelectedComponentId] = useState(null); + // 대시보드 페이지 미리보기 인덱스 (-1 = 기본 모드) + const [previewPageIndex, setPreviewPageIndex] = useState(-1); + // 그리드 모드 (4개 프리셋) const [currentMode, setCurrentMode] = useState("tablet_landscape"); - // 선택된 컴포넌트 - const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId - ? layout.components[selectedComponentId] || null - : null; + // 모달 캔버스 활성 상태 ("main" 또는 모달 ID) + const [activeCanvasId, setActiveCanvasId] = useState("main"); + + // 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회) + const selectedComponent: PopComponentDefinitionV5 | null = (() => { + if (!selectedComponentId) return null; + if (activeCanvasId === "main") { + return layout.components[selectedComponentId] || null; + } + const modal = layout.modals?.find(m => m.id === activeCanvasId); + return modal?.components[selectedComponentId] || null; + })(); // ======================================== // 히스토리 관리 @@ -206,52 +222,169 @@ export default function PopDesigner({ (type: PopComponentType, position: PopGridPosition) => { const componentId = `comp_${idCounter}`; setIdCounter((prev) => prev + 1); - const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`); - setLayout(newLayout); - saveToHistory(newLayout); + + if (activeCanvasId === "main") { + // 메인 캔버스 + const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`); + setLayout(newLayout); + saveToHistory(newLayout); + } else { + // 모달 캔버스 + setLayout(prev => { + const comp = createComponentDefinitionV5(componentId, type, position, `${type} ${idCounter}`); + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + return { ...m, components: { ...m.components, [componentId]: comp } }; + }), + }; + saveToHistory(newLayout); + return newLayout; + }); + } setSelectedComponentId(componentId); setHasChanges(true); }, - [idCounter, layout, saveToHistory] + [idCounter, layout, saveToHistory, activeCanvasId] ); const handleUpdateComponent = useCallback( (componentId: string, updates: Partial) => { - const existingComponent = layout.components[componentId]; - if (!existingComponent) return; + // 함수적 업데이트로 stale closure 방지 + setLayout((prev) => { + if (activeCanvasId === "main") { + // 메인 캔버스 + const existingComponent = prev.components[componentId]; + if (!existingComponent) return prev; - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...existingComponent, - ...updates, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); + const newLayout = { + ...prev, + components: { + ...prev.components, + [componentId]: { ...existingComponent, ...updates }, + }, + }; + saveToHistory(newLayout); + return newLayout; + } else { + // 모달 캔버스 + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const existing = m.components[componentId]; + if (!existing) return m; + return { + ...m, + components: { + ...m.components, + [componentId]: { ...existing, ...updates }, + }, + }; + }), + }; + saveToHistory(newLayout); + return newLayout; + } + }); setHasChanges(true); }, - [layout, saveToHistory] + [saveToHistory, activeCanvasId] + ); + + // ======================================== + // 연결 CRUD + // ======================================== + + const handleAddConnection = useCallback( + (conn: Omit) => { + setLayout((prev) => { + const newId = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const newConnection: PopDataConnection = { ...conn, id: newId }; + const prevConnections = prev.dataFlow?.connections || []; + const newLayout: PopLayoutDataV5 = { + ...prev, + dataFlow: { + ...prev.dataFlow, + connections: [...prevConnections, newConnection], + }, + }; + saveToHistory(newLayout); + return newLayout; + }); + setHasChanges(true); + }, + [saveToHistory] + ); + + const handleUpdateConnection = useCallback( + (connectionId: string, conn: Omit) => { + setLayout((prev) => { + const prevConnections = prev.dataFlow?.connections || []; + const newLayout: PopLayoutDataV5 = { + ...prev, + dataFlow: { + ...prev.dataFlow, + connections: prevConnections.map((c) => + c.id === connectionId ? { ...conn, id: connectionId } : c + ), + }, + }; + saveToHistory(newLayout); + return newLayout; + }); + setHasChanges(true); + }, + [saveToHistory] + ); + + const handleRemoveConnection = useCallback( + (connectionId: string) => { + setLayout((prev) => { + const prevConnections = prev.dataFlow?.connections || []; + const newLayout: PopLayoutDataV5 = { + ...prev, + dataFlow: { + ...prev.dataFlow, + connections: prevConnections.filter((c) => c.id !== connectionId), + }, + }; + saveToHistory(newLayout); + return newLayout; + }); + setHasChanges(true); + }, + [saveToHistory] ); const handleDeleteComponent = useCallback( (componentId: string) => { - const newComponents = { ...layout.components }; - delete newComponents[componentId]; - - const newLayout = { - ...layout, - components: newComponents, - }; - setLayout(newLayout); - saveToHistory(newLayout); + setLayout(prev => { + if (activeCanvasId === "main") { + const newComponents = { ...prev.components }; + delete newComponents[componentId]; + const newLayout = { ...prev, components: newComponents }; + saveToHistory(newLayout); + return newLayout; + } else { + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const newComps = { ...m.components }; + delete newComps[componentId]; + return { ...m, components: newComps }; + }), + }; + saveToHistory(newLayout); + return newLayout; + } + }); setSelectedComponentId(null); setHasChanges(true); }, - [layout, saveToHistory] + [saveToHistory, activeCanvasId] ); const handleMoveComponent = useCallback( @@ -357,6 +490,56 @@ export default function PopDesigner({ [layout, saveToHistory] ); + // 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등) + const handleRequestResize = useCallback( + (componentId: string, newRowSpan: number, newColSpan?: number) => { + const component = layout.components[componentId]; + if (!component) return; + + const newPosition = { + ...component.position, + rowSpan: newRowSpan, + ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), + }; + + // 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정 + if (currentMode === "tablet_landscape") { + const newLayout = { + ...layout, + components: { + ...layout.components, + [componentId]: { + ...component, + position: newPosition, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + } else { + // 다른 모드인 경우: 오버라이드에 저장 + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + positions: { + ...layout.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + } + }, + [layout, currentMode, saveToHistory] + ); + // ======================================== // Gap 프리셋 관리 // ======================================== @@ -471,6 +654,59 @@ export default function PopDesigner({ setHasChanges(true); }, [layout, currentMode, saveToHistory]); + // ======================================== + // 모달 캔버스 관리 + // ======================================== + + /** 모달 ID 자동 생성 (계층적: modal-1, modal-1-1, modal-1-1-1) */ + const generateModalId = useCallback((parentCanvasId: string): string => { + const modals = layout.modals || []; + if (parentCanvasId === "main") { + const rootModals = modals.filter(m => !m.parentId); + return `modal-${rootModals.length + 1}`; + } + const prefix = parentCanvasId.replace("modal-", ""); + const children = modals.filter(m => m.parentId === parentCanvasId); + return `modal-${prefix}-${children.length + 1}`; + }, [layout.modals]); + + /** 모달 캔버스 생성하고 해당 탭으로 전환 */ + const createModalCanvas = useCallback((buttonComponentId: string, title: string): string => { + const modalId = generateModalId(activeCanvasId); + const newModal: PopModalDefinition = { + id: modalId, + parentId: activeCanvasId === "main" ? undefined : activeCanvasId, + title: title || "새 모달", + sourceButtonId: buttonComponentId, + gridConfig: { ...layout.gridConfig }, + components: {}, + }; + setLayout(prev => ({ + ...prev, + modals: [...(prev.modals || []), newModal], + })); + setHasChanges(true); + setActiveCanvasId(modalId); + return modalId; + }, [generateModalId, activeCanvasId, layout.gridConfig]); + + /** 모달 정의 업데이트 (제목, sizeConfig 등) */ + const handleUpdateModal = useCallback((modalId: string, updates: Partial) => { + setLayout(prev => ({ + ...prev, + modals: (prev.modals || []).map(m => + m.id === modalId ? { ...m, ...updates } : m + ), + })); + setHasChanges(true); + }, []); + + /** 특정 캔버스로 전환 */ + const navigateToCanvas = useCallback((canvasId: string) => { + setActiveCanvasId(canvasId); + setSelectedComponentId(null); + }, []); + // ======================================== // 뒤로가기 // ======================================== @@ -553,6 +789,14 @@ export default function PopDesigner({ // 렌더링 // ======================================== return ( +
{/* 헤더 */} @@ -637,6 +881,11 @@ export default function PopDesigner({ onLockLayout={handleLockLayout} onResetOverride={handleResetOverride} onChangeGapPreset={handleChangeGapPreset} + onRequestResize={handleRequestResize} + previewPageIndex={previewPageIndex} + activeCanvasId={activeCanvasId} + onActiveCanvasChange={navigateToCanvas} + onUpdateModal={handleUpdateModal} /> @@ -652,10 +901,21 @@ export default function PopDesigner({ ? (updates) => handleUpdateComponent(selectedComponentId, updates) : undefined } + allComponents={Object.values(layout.components)} + onSelectComponent={setSelectedComponentId} + selectedComponentId={selectedComponentId} + previewPageIndex={previewPageIndex} + onPreviewPage={setPreviewPageIndex} + connections={layout.dataFlow?.connections || []} + onAddConnection={handleAddConnection} + onUpdateConnection={handleUpdateConnection} + onRemoveConnection={handleRemoveConnection} + modals={layout.modals} />
+
); } diff --git a/frontend/components/pop/designer/PopDesignerContext.tsx b/frontend/components/pop/designer/PopDesignerContext.tsx new file mode 100644 index 00000000..8af42d64 --- /dev/null +++ b/frontend/components/pop/designer/PopDesignerContext.tsx @@ -0,0 +1,35 @@ +/** + * PopDesignerContext - 디자이너 전역 컨텍스트 + * + * ConfigPanel 등 하위 컴포넌트에서 디자이너 레벨 동작을 트리거하기 위한 컨텍스트. + * 예: pop-button 설정 패널에서 "모달 캔버스 생성" 버튼 클릭 시 + * 디자이너의 activeCanvasId를 변경하고 새 모달을 생성. + * + * Provider: PopDesigner.tsx + * Consumer: pop-button ConfigPanel (ModalCanvasButton) + */ + +"use client"; + +import { createContext, useContext } from "react"; + +export interface PopDesignerContextType { + /** 새 모달 캔버스 생성하고 해당 탭으로 전환 (모달 ID 반환) */ + createModalCanvas: (buttonComponentId: string, title: string) => string; + /** 특정 캔버스(메인 또는 모달)로 전환 */ + navigateToCanvas: (canvasId: string) => void; + /** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */ + activeCanvasId: string; + /** 현재 선택된 컴포넌트 ID */ + selectedComponentId: string | null; +} + +export const PopDesignerContext = createContext(null); + +/** + * 디자이너 컨텍스트 사용 훅 + * 뷰어 모드에서는 null 반환 (Provider 없음) + */ +export function usePopDesignerContext(): PopDesignerContextType | null { + return useContext(PopDesignerContext); +} diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index ddb7ac79..12c21e4f 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -7,21 +7,23 @@ import { PopGridPosition, GridMode, GRID_BREAKPOINTS, - PopComponentType, } from "../types/pop-layout"; import { Settings, - Database, + Link2, Eye, Grid3x3, MoveHorizontal, MoveVertical, + Layers, } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { PopDataConnection, PopModalDefinition } from "../types/pop-layout"; +import ConnectionEditor from "./ConnectionEditor"; // ======================================== // Props @@ -36,14 +38,41 @@ interface ComponentEditorPanelProps { onUpdateComponent?: (updates: Partial) => void; /** 추가 className */ className?: string; + /** 그리드에 배치된 모든 컴포넌트 */ + allComponents?: PopComponentDefinitionV5[]; + /** 컴포넌트 선택 콜백 */ + onSelectComponent?: (componentId: string) => void; + /** 현재 선택된 컴포넌트 ID */ + selectedComponentId?: string | null; + /** 대시보드 페이지 미리보기 인덱스 */ + previewPageIndex?: number; + /** 페이지 미리보기 요청 콜백 */ + onPreviewPage?: (pageIndex: number) => void; + /** 데이터 흐름 연결 목록 */ + connections?: PopDataConnection[]; + /** 연결 추가 콜백 */ + onAddConnection?: (conn: Omit) => void; + /** 연결 수정 콜백 */ + onUpdateConnection?: (connectionId: string, conn: Omit) => void; + /** 연결 삭제 콜백 */ + onRemoveConnection?: (connectionId: string) => void; + /** 모달 정의 목록 (설정 패널에 전달) */ + modals?: PopModalDefinition[]; } // ======================================== // 컴포넌트 타입별 라벨 // ======================================== -const COMPONENT_TYPE_LABELS: Record = { +const COMPONENT_TYPE_LABELS: Record = { + "pop-sample": "샘플", + "pop-text": "텍스트", + "pop-icon": "아이콘", + "pop-dashboard": "대시보드", + "pop-card-list": "카드 목록", "pop-field": "필드", "pop-button": "버튼", + "pop-string-list": "리스트 목록", + "pop-search": "검색", "pop-list": "리스트", "pop-indicator": "인디케이터", "pop-scanner": "스캐너", @@ -61,6 +90,16 @@ export default function ComponentEditorPanel({ currentMode, onUpdateComponent, className, + allComponents, + onSelectComponent, + selectedComponentId, + previewPageIndex, + onPreviewPage, + connections, + onAddConnection, + onUpdateConnection, + onRemoveConnection, + modals, }: ComponentEditorPanelProps) { const breakpoint = GRID_BREAKPOINTS[currentMode]; @@ -97,8 +136,8 @@ export default function ComponentEditorPanel({
{/* 탭 */} - - + + 위치 @@ -111,14 +150,51 @@ export default function ComponentEditorPanel({ 표시 - - - 데이터 + + + 연결 {/* 위치 탭 */} - + + {/* 배치된 컴포넌트 목록 */} + {allComponents && allComponents.length > 0 && ( +
+
+ + + 배치된 컴포넌트 ({allComponents.length}) + +
+
+ {allComponents.map((comp) => { + const label = comp.label + || COMPONENT_TYPE_LABELS[comp.type] + || comp.type; + const isActive = comp.id === selectedComponentId; + return ( + + ); + })} +
+
+
+ )} {/* 설정 탭 */} - + {/* 표시 탭 */} - + - {/* 데이터 탭 */} - - + {/* 연결 탭 */} + +
@@ -313,9 +402,15 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate interface ComponentSettingsFormProps { component: PopComponentDefinitionV5; onUpdate?: (updates: Partial) => void; + currentMode?: GridMode; + previewPageIndex?: number; + onPreviewPage?: (pageIndex: number) => void; + modals?: PopModalDefinition[]; + allComponents?: PopComponentDefinitionV5[]; + connections?: PopDataConnection[]; } -function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) { +function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals, allComponents, connections }: ComponentSettingsFormProps) { // PopComponentRegistry에서 configPanel 가져오기 const registeredComp = PopComponentRegistry.getComponent(component.type); const ConfigPanel = registeredComp?.configPanel; @@ -344,6 +439,14 @@ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormPro ) : (
@@ -419,20 +522,4 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { ); } -// ======================================== -// 데이터 바인딩 플레이스홀더 -// ======================================== -function DataBindingPlaceholder() { - return ( -
-
- -

데이터 바인딩

-

- Phase 4에서 구현 예정 -

-
-
- ); -} diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 05db0aab..42b1ee06 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -27,6 +27,42 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: FileText, description: "텍스트, 시간, 이미지 표시", }, + { + type: "pop-icon", + label: "아이콘", + icon: MousePointer, + description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)", + }, + { + type: "pop-dashboard", + label: "대시보드", + icon: BarChart3, + description: "KPI, 차트, 게이지, 통계 집계", + }, + { + type: "pop-card-list", + label: "카드 목록", + icon: LayoutGrid, + description: "테이블 데이터를 카드 형태로 표시", + }, + { + type: "pop-button", + label: "버튼", + icon: MousePointerClick, + description: "액션 버튼 (저장/삭제/API/모달)", + }, + { + type: "pop-string-list", + label: "리스트 목록", + icon: List, + description: "테이블 데이터를 리스트/카드로 표시", + }, + { + type: "pop-search", + label: "검색", + icon: Search, + description: "조건 입력 (텍스트/날짜/선택/모달)", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx new file mode 100644 index 00000000..725b4f3f --- /dev/null +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -0,0 +1,655 @@ +"use client"; + +import React from "react"; +import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + PopComponentDefinitionV5, + PopDataConnection, +} from "../types/pop-layout"; +import { + PopComponentRegistry, + type ComponentConnectionMeta, +} from "@/lib/registry/PopComponentRegistry"; +import { getTableColumns } from "@/lib/api/tableManagement"; + +// ======================================== +// Props +// ======================================== + +interface ConnectionEditorProps { + component: PopComponentDefinitionV5; + allComponents: PopComponentDefinitionV5[]; + connections: PopDataConnection[]; + onAddConnection?: (conn: Omit) => void; + onUpdateConnection?: (connectionId: string, conn: Omit) => void; + onRemoveConnection?: (connectionId: string) => void; +} + +// ======================================== +// ConnectionEditor +// ======================================== + +export default function ConnectionEditor({ + component, + allComponents, + connections, + onAddConnection, + onUpdateConnection, + onRemoveConnection, +}: ConnectionEditorProps) { + const registeredComp = PopComponentRegistry.getComponent(component.type); + const meta = registeredComp?.connectionMeta; + + const outgoing = connections.filter( + (c) => c.sourceComponent === component.id + ); + const incoming = connections.filter( + (c) => c.targetComponent === component.id + ); + + const hasSendable = meta?.sendable && meta.sendable.length > 0; + const hasReceivable = meta?.receivable && meta.receivable.length > 0; + + if (!hasSendable && !hasReceivable) { + return ( +
+
+ +

연결 없음

+

+ 이 컴포넌트는 다른 컴포넌트와 연결할 수 없습니다 +

+
+
+ ); + } + + return ( +
+ {hasSendable && ( + + )} + + {hasReceivable && ( + + )} +
+ ); +} + +// ======================================== +// 대상 컴포넌트에서 정보 추출 +// ======================================== + +/** 화면에 표시 중인 컬럼만 추출 */ +function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] { + if (!comp?.config) return []; + const cfg = comp.config as Record; + const cols: string[] = []; + + if (Array.isArray(cfg.listColumns)) { + (cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => { + if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName); + }); + } + + if (Array.isArray(cfg.selectedColumns)) { + (cfg.selectedColumns as string[]).forEach((c) => { + if (!cols.includes(c)) cols.push(c); + }); + } + + return cols; +} + +/** 대상 컴포넌트의 데이터소스 테이블명 추출 */ +function extractTableName(comp: PopComponentDefinitionV5 | undefined): string { + if (!comp?.config) return ""; + const cfg = comp.config as Record; + const ds = cfg.dataSource as { tableName?: string } | undefined; + return ds?.tableName || ""; +} + +// ======================================== +// 보내기 섹션 +// ======================================== + +interface SendSectionProps { + component: PopComponentDefinitionV5; + meta: ComponentConnectionMeta; + allComponents: PopComponentDefinitionV5[]; + outgoing: PopDataConnection[]; + onAddConnection?: (conn: Omit) => void; + onUpdateConnection?: (connectionId: string, conn: Omit) => void; + onRemoveConnection?: (connectionId: string) => void; +} + +function SendSection({ + component, + meta, + allComponents, + outgoing, + onAddConnection, + onUpdateConnection, + onRemoveConnection, +}: SendSectionProps) { + const [editingId, setEditingId] = React.useState(null); + + return ( +
+ + + {/* 기존 연결 목록 */} + {outgoing.map((conn) => ( +
+ {editingId === conn.id ? ( + { + onUpdateConnection?.(conn.id, data); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + submitLabel="수정" + /> + ) : ( +
+ + {conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`} + + + {onRemoveConnection && ( + + )} +
+ )} +
+ ))} + + {/* 새 연결 추가 */} + onAddConnection?.(data)} + submitLabel="연결 추가" + /> +
+ ); +} + +// ======================================== +// 연결 폼 (추가/수정 공용) +// ======================================== + +interface ConnectionFormProps { + component: PopComponentDefinitionV5; + meta: ComponentConnectionMeta; + allComponents: PopComponentDefinitionV5[]; + initial?: PopDataConnection; + onSubmit: (data: Omit) => void; + onCancel?: () => void; + submitLabel: string; +} + +function ConnectionForm({ + component, + meta, + allComponents, + initial, + onSubmit, + onCancel, + submitLabel, +}: ConnectionFormProps) { + const [selectedOutput, setSelectedOutput] = React.useState( + initial?.sourceOutput || meta.sendable[0]?.key || "" + ); + const [selectedTargetId, setSelectedTargetId] = React.useState( + initial?.targetComponent || "" + ); + const [selectedTargetInput, setSelectedTargetInput] = React.useState( + initial?.targetInput || "" + ); + const [filterColumns, setFilterColumns] = React.useState( + initial?.filterConfig?.targetColumns || + (initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : []) + ); + const [filterMode, setFilterMode] = React.useState< + "equals" | "contains" | "starts_with" | "range" + >(initial?.filterConfig?.filterMode || "contains"); + + const targetCandidates = allComponents.filter((c) => { + if (c.id === component.id) return false; + const reg = PopComponentRegistry.getComponent(c.type); + return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; + }); + + const targetComp = selectedTargetId + ? allComponents.find((c) => c.id === selectedTargetId) + : null; + + const targetMeta = targetComp + ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta + : null; + + // 보내는 값 + 받는 컴포넌트가 결정되면 받는 방식 자동 매칭 + React.useEffect(() => { + if (!selectedOutput || !targetMeta?.receivable?.length) return; + // 이미 선택된 값이 있으면 건드리지 않음 + if (selectedTargetInput) return; + + const receivables = targetMeta.receivable; + // 1) 같은 key가 있으면 자동 매칭 + const exactMatch = receivables.find((r) => r.key === selectedOutput); + if (exactMatch) { + setSelectedTargetInput(exactMatch.key); + return; + } + // 2) receivable이 1개뿐이면 자동 선택 + if (receivables.length === 1) { + setSelectedTargetInput(receivables[0].key); + } + }, [selectedOutput, targetMeta, selectedTargetInput]); + + // 화면에 표시 중인 컬럼 + const displayColumns = React.useMemo( + () => extractDisplayColumns(targetComp || undefined), + [targetComp] + ); + + // DB 테이블 전체 컬럼 (비동기 조회) + const tableName = React.useMemo( + () => extractTableName(targetComp || undefined), + [targetComp] + ); + const [allDbColumns, setAllDbColumns] = React.useState([]); + const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false); + + React.useEffect(() => { + if (!tableName) { + setAllDbColumns([]); + return; + } + let cancelled = false; + setDbColumnsLoading(true); + getTableColumns(tableName).then((res) => { + if (cancelled) return; + if (res.success && res.data?.columns) { + setAllDbColumns(res.data.columns.map((c) => c.columnName)); + } else { + setAllDbColumns([]); + } + setDbColumnsLoading(false); + }); + return () => { cancelled = true; }; + }, [tableName]); + + // 표시 컬럼과 데이터 전용 컬럼 분리 + const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]); + const dataOnlyColumns = React.useMemo( + () => allDbColumns.filter((c) => !displaySet.has(c)), + [allDbColumns, displaySet] + ); + const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0; + + const toggleColumn = (col: string) => { + setFilterColumns((prev) => + prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col] + ); + }; + + const handleSubmit = () => { + if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return; + + const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput); + + onSubmit({ + sourceComponent: component.id, + sourceField: "", + sourceOutput: selectedOutput, + targetComponent: selectedTargetId, + targetField: "", + targetInput: selectedTargetInput, + filterConfig: + !isEvent && filterColumns.length > 0 + ? { + targetColumn: filterColumns[0], + targetColumns: filterColumns, + filterMode, + } + : undefined, + label: buildConnectionLabel( + component, + selectedOutput, + allComponents.find((c) => c.id === selectedTargetId), + selectedTargetInput, + filterColumns + ), + }); + + if (!initial) { + setSelectedTargetId(""); + setSelectedTargetInput(""); + setFilterColumns([]); + } + }; + + return ( +
+ {onCancel && ( +
+

연결 수정

+ +
+ )} + {!onCancel && ( +

새 연결 추가

+ )} + + {/* 보내는 값 */} +
+ 보내는 값 + +
+ + {/* 받는 컴포넌트 */} +
+ 받는 컴포넌트 + +
+ + {/* 받는 방식 */} + {targetMeta && ( +
+ 받는 방식 + +
+ )} + + {/* 필터 설정: event 타입 연결이면 숨김 */} + {selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && ( +
+

필터할 컬럼

+ + {dbColumnsLoading ? ( +
+ + 컬럼 조회 중... +
+ ) : hasAnyColumns ? ( +
+ {/* 표시 컬럼 그룹 */} + {displayColumns.length > 0 && ( +
+

화면 표시 컬럼

+ {displayColumns.map((col) => ( +
+ toggleColumn(col)} + /> + +
+ ))} +
+ )} + + {/* 데이터 전용 컬럼 그룹 */} + {dataOnlyColumns.length > 0 && ( +
+ {displayColumns.length > 0 && ( +
+ )} +

데이터 전용 컬럼

+ {dataOnlyColumns.map((col) => ( +
+ toggleColumn(col)} + /> + +
+ ))} +
+ )} +
+ ) : ( + setFilterColumns(e.target.value ? [e.target.value] : [])} + placeholder="컬럼명 입력" + className="h-7 text-xs" + /> + )} + + {filterColumns.length > 0 && ( +

+ {filterColumns.length}개 컬럼 중 하나라도 일치하면 표시 +

+ )} + + {/* 필터 방식 */} +
+

필터 방식

+ +
+
+ )} + + {/* 제출 버튼 */} + +
+ ); +} + +// ======================================== +// 받기 섹션 (읽기 전용) +// ======================================== + +interface ReceiveSectionProps { + component: PopComponentDefinitionV5; + meta: ComponentConnectionMeta; + allComponents: PopComponentDefinitionV5[]; + incoming: PopDataConnection[]; +} + +function ReceiveSection({ + component, + meta, + allComponents, + incoming, +}: ReceiveSectionProps) { + return ( +
+ + +
+ {meta.receivable.map((r) => ( +
+ {r.label} + {r.description && ( +

+ {r.description} +

+ )} +
+ ))} +
+ + {incoming.length > 0 ? ( +
+

연결된 소스

+ {incoming.map((conn) => { + const sourceComp = allComponents.find( + (c) => c.id === conn.sourceComponent + ); + return ( +
+ + + {sourceComp?.label || conn.sourceComponent} + +
+ ); + })} +
+ ) : ( +

+ 아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요. +

+ )} +
+ ); +} + +// ======================================== +// 유틸 +// ======================================== + +function isEventTypeConnection( + sourceMeta: ComponentConnectionMeta | undefined, + outputKey: string, + targetMeta: ComponentConnectionMeta | null | undefined, + inputKey: string, +): boolean { + const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey); + const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey); + return sourceItem?.type === "event" || targetItem?.type === "event"; +} + +function buildConnectionLabel( + source: PopComponentDefinitionV5, + _outputKey: string, + target: PopComponentDefinitionV5 | undefined, + _inputKey: string, + columns?: string[] +): string { + const srcLabel = source.label || source.id; + const tgtLabel = target?.label || target?.id || "?"; + const colInfo = columns && columns.length > 0 + ? ` [${columns.join(", ")}]` + : ""; + return `${srcLabel} -> ${tgtLabel}${colInfo}`; +} diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index b0299813..88100d27 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -48,12 +48,18 @@ interface PopRendererProps { onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; /** 컴포넌트 크기 조정 완료 (히스토리 저장용) */ onComponentResizeEnd?: (componentId: string) => void; + /** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */ + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; /** Gap 오버라이드 (Gap 프리셋 적용된 값) */ overrideGap?: number; /** Padding 오버라이드 (Gap 프리셋 적용된 값) */ overridePadding?: number; /** 추가 className */ className?: string; + /** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */ + currentScreenId?: number; + /** 대시보드 페이지 미리보기 인덱스 */ + previewPageIndex?: number; } // ======================================== @@ -62,6 +68,13 @@ interface PopRendererProps { const COMPONENT_TYPE_LABELS: Record = { "pop-sample": "샘플", + "pop-text": "텍스트", + "pop-icon": "아이콘", + "pop-dashboard": "대시보드", + "pop-card-list": "카드 목록", + "pop-button": "버튼", + "pop-string-list": "리스트 목록", + "pop-search": "검색", }; // ======================================== @@ -80,9 +93,12 @@ export default function PopRenderer({ onComponentMove, onComponentResize, onComponentResizeEnd, + onRequestResize, overrideGap, overridePadding, className, + currentScreenId, + previewPageIndex, }: PopRendererProps) { const { gridConfig, components, overrides } = layout; @@ -110,18 +126,27 @@ export default function PopRenderer({ return Math.max(10, maxRowEnd + 3); }, [components, overrides, mode, hiddenIds]); - // CSS Grid 스타일 (행 높이 강제 고정: 셀 크기 = 컴포넌트 크기의 기준) + // CSS Grid 스타일 + // 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집) + // 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능) + const rowTemplate = isDesignMode + ? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)` + : `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`; + const autoRowHeight = isDesignMode + ? `${breakpoint.rowHeight}px` + : `minmax(${breakpoint.rowHeight}px, auto)`; + const gridStyle = useMemo((): React.CSSProperties => ({ display: "grid", gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, - gridTemplateRows: `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`, - gridAutoRows: `${breakpoint.rowHeight}px`, + gridTemplateRows: rowTemplate, + gridAutoRows: autoRowHeight, gap: `${finalGap}px`, padding: `${finalPadding}px`, minHeight: "100%", backgroundColor: "#ffffff", position: "relative", - }), [breakpoint, finalGap, finalPadding, dynamicRowCount]); + }), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]); // 그리드 가이드 셀 생성 (동적 행 수) const gridCells = useMemo(() => { @@ -248,15 +273,17 @@ export default function PopRenderer({ onComponentMove={onComponentMove} onComponentResize={onComponentResize} onComponentResizeEnd={onComponentResizeEnd} + onRequestResize={onRequestResize} + previewPageIndex={previewPageIndex} /> ); } - // 뷰어 모드: 드래그 없는 일반 렌더링 + // 뷰어 모드: 드래그 없는 일반 렌더링 (overflow visible로 컨텐츠 확장 허용) return (
); @@ -291,6 +320,8 @@ interface DraggableComponentProps { onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void; onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; onComponentResizeEnd?: (componentId: string) => void; + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; + previewPageIndex?: number; } function DraggableComponent({ @@ -308,6 +339,8 @@ function DraggableComponent({ onComponentMove, onComponentResize, onComponentResizeEnd, + onRequestResize, + previewPageIndex, }: DraggableComponentProps) { const [{ isDragging }, drag] = useDrag( () => ({ @@ -346,6 +379,9 @@ function DraggableComponent({ effectivePosition={position} isDesignMode={isDesignMode} isSelected={isSelected} + previewPageIndex={previewPageIndex} + onRequestResize={onRequestResize} + screenId={undefined} /> {/* 리사이즈 핸들 (선택된 컴포넌트만) */} @@ -496,66 +532,99 @@ interface ComponentContentProps { effectivePosition: PopGridPosition; isDesignMode: boolean; isSelected: boolean; + previewPageIndex?: number; + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; + /** 화면 ID (이벤트 버스/액션 실행용) */ + screenId?: string; } -function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) { +function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex, onRequestResize, screenId }: ComponentContentProps) { const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; // PopComponentRegistry에서 등록된 컴포넌트 가져오기 const registeredComp = PopComponentRegistry.getComponent(component.type); const PreviewComponent = registeredComp?.preview; - // 디자인 모드: 미리보기 컴포넌트 또는 플레이스홀더 표시 + // 디자인 모드: 실제 컴포넌트 또는 미리보기 표시 (헤더 없음 - 뷰어와 동일하게) if (isDesignMode) { + const ActualComp = registeredComp?.component; + + // 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등) + if (ActualComp) { + // 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용 + // CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용 + const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list"; + + return ( +
+ +
+ ); + } + + // 미등록: preview 컴포넌트 또는 기본 플레이스홀더 return ( -
- {/* 헤더 */} -
- - {component.label || typeLabel} +
+ {PreviewComponent ? ( + + ) : ( + + {typeLabel} -
- - {/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */} -
- {PreviewComponent ? ( - - ) : ( - - {typeLabel} - - )} -
- - {/* 위치 정보 표시 (유효 위치 사용) */} -
- {effectivePosition.col},{effectivePosition.row} - ({effectivePosition.colSpan}×{effectivePosition.rowSpan}) -
+ )}
); } - // 실제 모드: 컴포넌트 렌더링 - return renderActualComponent(component); + // 실제 모드: 컴포넌트 렌더링 (뷰어 모드에서도 리사이즈 지원) + return renderActualComponent(component, effectivePosition, onRequestResize, screenId); } // ======================================== // 실제 컴포넌트 렌더링 (뷰어 모드) // ======================================== -function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode { - const typeLabel = COMPONENT_TYPE_LABELS[component.type]; - - // 샘플 박스 렌더링 +function renderActualComponent( + component: PopComponentDefinitionV5, + effectivePosition?: PopGridPosition, + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void, + screenId?: string, +): React.ReactNode { + // 레지스트리에서 등록된 실제 컴포넌트 조회 + const registeredComp = PopComponentRegistry.getComponent(component.type); + const ActualComp = registeredComp?.component; + + if (ActualComp) { + return ( +
+ +
+ ); + } + + // 미등록 컴포넌트: 플레이스홀더 (fallback) + const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; return (
{component.label || typeLabel} diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 1a8335ec..15e70c65 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -9,7 +9,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트 +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search"; /** * 데이터 흐름 정의 @@ -25,6 +25,16 @@ export interface PopDataConnection { targetComponent: string; targetField: string; transformType?: "direct" | "calculate" | "lookup"; + + // v2: 연결 시스템 전용 + sourceOutput?: string; + targetInput?: string; + filterConfig?: { + targetColumn: string; + targetColumns?: string[]; + filterMode: "equals" | "contains" | "starts_with" | "range"; + }; + label?: string; } /** @@ -208,6 +218,9 @@ export interface PopLayoutDataV5 { mobile_landscape?: PopModeOverrideV5; tablet_portrait?: PopModeOverrideV5; }; + + // 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성) + modals?: PopModalDefinition[]; } /** @@ -342,6 +355,12 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { export const DEFAULT_COMPONENT_GRID_SIZE: Record = { "pop-sample": { colSpan: 2, rowSpan: 1 }, "pop-text": { colSpan: 3, rowSpan: 1 }, + "pop-icon": { colSpan: 1, rowSpan: 2 }, + "pop-dashboard": { colSpan: 6, rowSpan: 3 }, + "pop-card-list": { colSpan: 4, rowSpan: 3 }, + "pop-button": { colSpan: 2, rowSpan: 1 }, + "pop-string-list": { colSpan: 4, rowSpan: 3 }, + "pop-search": { colSpan: 4, rowSpan: 2 }, }; /** @@ -380,6 +399,95 @@ export const addComponentToV5Layout = ( return newLayout; }; +// ======================================== +// 모달 캔버스 정의 +// ======================================== + +// ======================================== +// 모달 사이즈 시스템 +// ======================================== + +/** 모달 사이즈 프리셋 */ +export type ModalSizePreset = "sm" | "md" | "lg" | "xl" | "full"; + +/** 모달 사이즈 프리셋별 픽셀 값 */ +export const MODAL_SIZE_PRESETS: Record = { + sm: { width: 400, label: "Small (400px)" }, + md: { width: 600, label: "Medium (600px)" }, + lg: { width: 800, label: "Large (800px)" }, + xl: { width: 1000, label: "XLarge (1000px)" }, + full: { width: 9999, label: "Full (화면 꽉 참)" }, +}; + +/** 모달 사이즈 설정 (모드별 독립 설정 가능) */ +export interface ModalSizeConfig { + /** 기본 사이즈 (모든 모드 공통, 기본값: "md") */ + default: ModalSizePreset; + /** 모드별 오버라이드 (미설정 시 default 사용) */ + modeOverrides?: { + mobile_portrait?: ModalSizePreset; + mobile_landscape?: ModalSizePreset; + tablet_portrait?: ModalSizePreset; + tablet_landscape?: ModalSizePreset; + }; +} + +/** + * 주어진 모드에서 모달의 실제 픽셀 너비를 계산 + * - 뷰포트보다 모달이 크면 자동으로 뷰포트에 맞춤 (full 승격) + */ +export function resolveModalWidth( + sizeConfig: ModalSizeConfig | undefined, + mode: GridMode, + viewportWidth: number, +): number { + const preset = sizeConfig?.modeOverrides?.[mode] ?? sizeConfig?.default ?? "md"; + const presetEntry = MODAL_SIZE_PRESETS[preset] ?? MODAL_SIZE_PRESETS.md; + const presetWidth = presetEntry.width; + // full이면 뷰포트 전체, 아니면 프리셋과 뷰포트 중 작은 값 + if (preset === "full") return viewportWidth; + return Math.min(presetWidth, viewportWidth); +} + +/** + * 모달 캔버스 정의 + * + * 버튼의 "모달 열기" 액션이 참조하는 모달 화면. + * 메인 캔버스와 동일한 그리드 시스템을 사용. + * 중첩 모달: parentId로 부모-자식 관계 표현. + */ +export interface PopModalDefinition { + /** 모달 고유 ID (예: "modal-1", "modal-1-1") */ + id: string; + /** 부모 모달 ID (최상위 모달은 undefined) */ + parentId?: string; + /** 모달 제목 (다이얼로그 헤더에 표시) */ + title: string; + /** 이 모달을 연 버튼의 컴포넌트 ID */ + sourceButtonId: string; + /** 모달 내부 그리드 설정 */ + gridConfig: PopGridConfig; + /** 모달 내부 컴포넌트 */ + components: Record; + /** 모드별 오버라이드 */ + overrides?: { + mobile_portrait?: PopModeOverrideV5; + mobile_landscape?: PopModeOverrideV5; + tablet_portrait?: PopModeOverrideV5; + }; + /** 모달 프레임 설정 (닫기 방식) */ + frameConfig?: { + /** 닫기(X) 버튼 표시 여부 (기본 true) */ + showCloseButton?: boolean; + /** 오버레이 클릭으로 닫기 (기본 true) */ + closeOnOverlay?: boolean; + /** ESC 키로 닫기 (기본 true) */ + closeOnEsc?: boolean; + }; + /** 모달 사이즈 설정 (미설정 시 md 기본) */ + sizeConfig?: ModalSizeConfig; +} + // ======================================== // 레거시 타입 별칭 (하위 호환 - 추후 제거) // ======================================== diff --git a/frontend/components/pop/viewer/PopViewerWithModals.tsx b/frontend/components/pop/viewer/PopViewerWithModals.tsx new file mode 100644 index 00000000..8d6e4227 --- /dev/null +++ b/frontend/components/pop/viewer/PopViewerWithModals.tsx @@ -0,0 +1,203 @@ +/** + * PopViewerWithModals - 뷰어 모드에서 모달 렌더링 래퍼 + * + * PopRenderer를 감싸서: + * 1. __pop_modal_open__ 이벤트 구독 → Dialog 열기 + * 2. __pop_modal_close__ 이벤트 구독 → Dialog 닫기 + * 3. 모달 스택 관리 (중첩 모달 지원) + * + * 모달 내부는 또 다른 PopRenderer로 렌더링 (독립 그리드). + */ + +"use client"; + +import { useState, useCallback, useEffect, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import PopRenderer from "../designer/renderers/PopRenderer"; +import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout"; +import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout"; +import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver"; + +// ======================================== +// 타입 +// ======================================== + +interface PopViewerWithModalsProps { + /** 전체 레이아웃 (모달 정의 포함) */ + layout: PopLayoutDataV5; + /** 뷰포트 너비 */ + viewportWidth: number; + /** 화면 ID (이벤트 버스용) */ + screenId: string; + /** 현재 그리드 모드 (PopRenderer 전달용) */ + currentMode?: GridMode; + /** Gap 오버라이드 */ + overrideGap?: number; + /** Padding 오버라이드 */ + overridePadding?: number; +} + +/** 열린 모달 상태 */ +interface OpenModal { + definition: PopModalDefinition; + returnTo?: string; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== + +export default function PopViewerWithModals({ + layout, + viewportWidth, + screenId, + currentMode, + overrideGap, + overridePadding, +}: PopViewerWithModalsProps) { + const [modalStack, setModalStack] = useState([]); + const { subscribe, publish } = usePopEvent(screenId); + + // 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환 + const stableConnections = useMemo( + () => layout.dataFlow?.connections ?? [], + [layout.dataFlow?.connections] + ); + useConnectionResolver({ + screenId, + connections: stableConnections, + }); + + // 모달 열기/닫기 이벤트 구독 + useEffect(() => { + const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => { + const data = payload as { + modalId?: string; + title?: string; + mode?: string; + returnTo?: string; + }; + + if (data?.modalId) { + const modalDef = layout.modals?.find(m => m.id === data.modalId); + if (modalDef) { + setModalStack(prev => [...prev, { + definition: modalDef, + returnTo: data.returnTo, + }]); + } + } + }); + + const unsubClose = subscribe("__pop_modal_close__", (payload: unknown) => { + const data = payload as { selectedRow?: Record } | undefined; + + setModalStack(prev => { + if (prev.length === 0) return prev; + const topModal = prev[prev.length - 1]; + + // 결과 데이터가 있고, 반환 대상이 지정된 경우 결과 이벤트 발행 + if (data?.selectedRow && topModal.returnTo) { + publish("__pop_modal_result__", { + selectedRow: data.selectedRow, + returnTo: topModal.returnTo, + }); + } + + return prev.slice(0, -1); + }); + }); + + return () => { + unsubOpen(); + unsubClose(); + }; + }, [subscribe, publish, layout.modals]); + + // 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC) + const handleCloseTopModal = useCallback(() => { + setModalStack(prev => prev.slice(0, -1)); + }, []); + + return ( + <> + {/* 메인 화면 렌더링 */} + + + {/* 모달 스택 렌더링 */} + {modalStack.map((modal, index) => { + const { definition } = modal; + const isTopModal = index === modalStack.length - 1; + const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false; + const closeOnEsc = definition.frameConfig?.closeOnEsc !== false; + + const modalLayout: PopLayoutDataV5 = { + ...layout, + gridConfig: definition.gridConfig, + components: definition.components, + overrides: definition.overrides, + }; + + const detectedMode = currentMode || detectGridMode(viewportWidth); + const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth); + const isFull = modalWidth >= viewportWidth; + const rendererWidth = isFull ? viewportWidth : modalWidth - 32; + + return ( + { + if (!open && isTopModal) handleCloseTopModal(); + }} + > + { + // 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지) + if (!isTopModal || !closeOnOverlay) e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + if (!isTopModal || !closeOnEsc) e.preventDefault(); + }} + > + + + {definition.title} + + +
+ +
+
+
+ ); + })} + + ); +} diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 41c51d85..664e7818 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -17,6 +17,7 @@ import { dynamicFormApi } from "@/lib/api/dynamicForm"; import { useAuth } from "@/hooks/useAuth"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; interface EditModalState { isOpen: boolean; @@ -1173,19 +1174,6 @@ export const EditModal: React.FC = ({ className }) => { if (response.success) { const masterRecordId = response.data?.id || formData.id; - // 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝) - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: masterRecordId, - masterRecordId, - mainFormData: formData, - tableName: screenData.screenInfo.tableName, - }, - }), - ); - console.log("📋 [EditModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName: screenData.screenInfo.tableName }); - toast.success("데이터가 생성되었습니다."); // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) @@ -1233,14 +1221,48 @@ export const EditModal: React.FC = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } + // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) + try { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); + + console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", { + parentId: masterRecordId, + tableName: screenData.screenInfo.tableName, + }); + + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: masterRecordId, + tableName: screenData.screenInfo.tableName, + mainFormData: formData, + masterRecordId, + }, + }), + ); + + await repeaterSavePromise; + console.log("✅ [EditModal] INSERT 후 repeaterSave 완료"); + } catch (repeaterError) { + console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + } + handleClose(); } else { throw new Error(response.message || "생성에 실패했습니다."); } } else { // UPDATE 모드 - PUT (전체 업데이트) - // originalData 비교 없이 formData 전체를 보냄 - const recordId = formData.id; + // VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조) + const recordId = formData.master_id || formData.id; if (!recordId) { console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", { @@ -1293,15 +1315,6 @@ export const EditModal: React.FC = ({ className }) => { if (response.success) { toast.success("데이터가 수정되었습니다."); - // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) - if (modalState.onSave) { - try { - modalState.onSave(); - } catch (callbackError) { - console.error("onSave 콜백 에러:", callbackError); - } - } - // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) try { @@ -1338,6 +1351,44 @@ export const EditModal: React.FC = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } + // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) + try { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); + + console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", { + parentId: recordId, + tableName: screenData.screenInfo.tableName, + }); + + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: recordId, + tableName: screenData.screenInfo.tableName, + mainFormData: formData, + masterRecordId: recordId, + }, + }), + ); + + await repeaterSavePromise; + console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료"); + } catch (repeaterError) { + console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + } + + // 리피터 저장 완료 후 메인 테이블 새로고침 + if (modalState.onSave) { + try { modalState.onSave(); } catch {} + } handleClose(); } else { throw new Error(response.message || "수정에 실패했습니다."); @@ -1404,12 +1455,16 @@ export const EditModal: React.FC = ({ className }) => {
) : screenData ? ( +
{ const baseHeight = (screenDimensions?.height || 600) + 30; if (activeConditionalComponents.length > 0) { @@ -1565,6 +1620,7 @@ export const EditModal: React.FC = ({ className }) => { ); })}
+
) : (

화면 데이터가 없습니다.

diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 30fc5539..a35c5ed2 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -571,8 +571,38 @@ export const InteractiveScreenViewerDynamic: React.FC { + const compType = c.componentType || c.overrides?.type; + if (compType !== "v2-repeater") return false; + const compConfig = c.componentConfig || c.overrides || {}; + return !compConfig.useCustomTable; + }); + + if (hasRepeaterOnSameTable) { + // 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장 + // 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리 + try { + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: null, + masterRecordId: null, + mainFormData: formData, + tableName: screenInfo.tableName, + }, + }), + ); + + toast.success("데이터가 성공적으로 저장되었습니다."); + } catch (error) { + toast.error("저장 중 오류가 발생했습니다."); + } + return; + } + try { - // 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) + // 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) // 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함 const masterFormData: Record = {}; @@ -591,11 +621,8 @@ export const InteractiveScreenViewerDynamic: React.FC { if (!Array.isArray(value)) { - // 배열이 아닌 값은 그대로 저장 masterFormData[key] = value; } else if (mediaColumnNames.has(key)) { - // v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응) - // 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용 masterFormData[key] = value.length > 0 ? value[0] : null; console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`); } else { @@ -608,7 +635,6 @@ export const InteractiveScreenViewerDynamic: React.FC(null); + // 다른 레이어의 컴포넌트 메타 정보 캐시 (데이터 전달 타겟 선택용) + const [otherLayerComponents, setOtherLayerComponents] = useState([]); + // 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기 useEffect(() => { if (activeLayerId <= 1 || !selectedScreen?.screenId) { @@ -578,6 +581,41 @@ export default function ScreenDesigner({ findZone(); }, [activeLayerId, selectedScreen?.screenId, zones]); + // 다른 레이어의 컴포넌트 메타 정보 로드 (데이터 전달 타겟 선택용) + useEffect(() => { + if (!selectedScreen?.screenId) return; + const loadOtherLayerComponents = async () => { + try { + const allLayers = await screenApi.getScreenLayers(selectedScreen.screenId); + const currentLayerId = activeLayerIdRef.current || 1; + const otherLayers = allLayers.filter((l: any) => l.layer_id !== currentLayerId && l.layer_id > 0); + + const components: ComponentData[] = []; + for (const layerInfo of otherLayers) { + try { + const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerInfo.layer_id); + const rawComps = layerData?.components; + if (rawComps && Array.isArray(rawComps)) { + for (const comp of rawComps) { + components.push({ + ...comp, + _layerName: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`, + _layerId: String(layerInfo.layer_id), + } as any); + } + } + } catch { + // 개별 레이어 로드 실패 무시 + } + } + setOtherLayerComponents(components); + } catch { + setOtherLayerComponents([]); + } + }; + loadOtherLayerComponents(); + }, [selectedScreen?.screenId, activeLayerId]); + // 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) const visibleComponents = useMemo(() => { return layout.components; @@ -6516,8 +6554,8 @@ export default function ScreenDesigner({ updateComponentProperty(selectedComponent.id, "style", style); } }} - allComponents={layout.components} // 🆕 플로우 위젯 감지용 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 + allComponents={[...layout.components, ...otherLayerComponents]} + menuObjid={menuObjid} /> )} diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index ea2febb1..4919ec33 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -92,13 +92,14 @@ export const ButtonConfigPanel: React.FC = ({ const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState>({}); // 블록별 테이블 Popover 열림 상태 const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState>({}); // 블록별 컬럼 Popover 열림 상태 - // 🆕 데이터 전달 필드 매핑용 상태 - const [mappingSourceColumns, setMappingSourceColumns] = useState>([]); + // 🆕 데이터 전달 필드 매핑용 상태 (멀티 테이블 매핑 지원) + const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState>>({}); const [mappingTargetColumns, setMappingTargetColumns] = useState>([]); - const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState>({}); - const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState>({}); - const [mappingSourceSearch, setMappingSourceSearch] = useState>({}); - const [mappingTargetSearch, setMappingTargetSearch] = useState>({}); + const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState>({}); + const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState>({}); + const [mappingSourceSearch, setMappingSourceSearch] = useState>({}); + const [mappingTargetSearch, setMappingTargetSearch] = useState>({}); + const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0); // 🆕 openModalWithData 전용 필드 매핑 상태 const [modalSourceColumns, setModalSourceColumns] = useState>([]); @@ -295,57 +296,57 @@ export const ButtonConfigPanel: React.FC = ({ } }; - // 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드 - useEffect(() => { - const sourceTable = config.action?.dataTransfer?.sourceTable; - const targetTable = config.action?.dataTransfer?.targetTable; + // 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드 + const loadMappingColumns = useCallback(async (tableName: string): Promise> => { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data.success) { + let columnData = response.data.data; + if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; + if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - const loadColumns = async () => { - if (sourceTable) { - try { - const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`); - if (response.data.success) { - let columnData = response.data.data; - if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; - if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - - if (Array.isArray(columnData)) { - const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, - })); - setMappingSourceColumns(columns); - } - } - } catch (error) { - console.error("소스 테이블 컬럼 로드 실패:", error); + if (Array.isArray(columnData)) { + return columnData.map((col: any) => ({ + name: col.name || col.columnName, + label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + })); } } + } catch (error) { + console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error); + } + return []; + }, []); - if (targetTable) { - try { - const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`); - if (response.data.success) { - let columnData = response.data.data; - if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; - if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; + useEffect(() => { + const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || []; + const legacySourceTable = config.action?.dataTransfer?.sourceTable; + const targetTable = config.action?.dataTransfer?.targetTable; - if (Array.isArray(columnData)) { - const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, - })); - setMappingTargetColumns(columns); - } - } - } catch (error) { - console.error("타겟 테이블 컬럼 로드 실패:", error); + const loadAll = async () => { + const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean); + if (legacySourceTable && !sourceTableNames.includes(legacySourceTable)) { + sourceTableNames.push(legacySourceTable); + } + + const newMap: Record> = {}; + for (const tbl of sourceTableNames) { + if (!mappingSourceColumnsMap[tbl]) { + newMap[tbl] = await loadMappingColumns(tbl); } } + if (Object.keys(newMap).length > 0) { + setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap })); + } + + if (targetTable && mappingTargetColumns.length === 0) { + const cols = await loadMappingColumns(targetTable); + setMappingTargetColumns(cols); + } }; - loadColumns(); - }, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]); + loadAll(); + }, [config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable, loadMappingColumns]); // 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드 useEffect(() => { @@ -2966,11 +2967,17 @@ export const ButtonConfigPanel: React.FC = ({ - {/* 데이터 제공 가능한 컴포넌트 필터링 */} + {/* 자동 탐색 옵션 (레이어별 테이블이 다를 때 유용) */} + +
+ 자동 탐색 (현재 활성 테이블) + (auto) +
+
+ {/* 데이터 제공 가능한 컴포넌트 필터링 (모든 레이어 포함) */} {allComponents .filter((comp: any) => { const type = comp.componentType || comp.type || ""; - // 데이터를 제공할 수 있는 컴포넌트 타입들 return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => type.includes(t), ); @@ -2978,11 +2985,17 @@ export const ButtonConfigPanel: React.FC = ({ .map((comp: any) => { const compType = comp.componentType || comp.type || "unknown"; const compLabel = comp.label || comp.componentConfig?.title || comp.id; + const layerName = comp._layerName; return (
{compLabel} ({compType}) + {layerName && ( + + {layerName} + + )}
); @@ -2999,7 +3012,9 @@ export const ButtonConfigPanel: React.FC = ({ )}
-

테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트

+

+ 레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다 +

@@ -3037,33 +3052,47 @@ export const ButtonConfigPanel: React.FC = ({ { - const currentSources = config.action?.dataTransfer?.additionalSources || []; - const newSources = [...currentSources]; - if (newSources.length === 0) { - newSources.push({ componentId: "", fieldName: e.target.value }); - } else { - newSources[0] = { ...newSources[0], fieldName: e.target.value }; - } - onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); - }} - className="h-8 text-xs" - /> -

타겟 테이블에 저장될 필드명

+ + + + + + + + + 컬럼을 찾을 수 없습니다. + + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: "" }); + } else { + newSources[0] = { ...newSources[0], fieldName: "" }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="text-xs" + > + + 선택 안 함 (전체 데이터 병합) + + {(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => ( + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: col.name }); + } else { + newSources[0] = { ...newSources[0], fieldName: col.name }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="text-xs" + > + + {col.label || col.name} + {col.label && col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +

추가 데이터가 저장될 타겟 테이블 컬럼

- {/* 필드 매핑 규칙 */} + {/* 멀티 테이블 필드 매핑 */}
- {/* 소스/타겟 테이블 선택 */} -
-
- - - - - - - - - - 테이블을 찾을 수 없습니다 - - {availableTables.map((table) => ( - { - onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name); - }} - className="text-xs" - > - - {table.label} - ({table.name}) - - ))} - - - - - -
- -
- - - - - - - - - - 테이블을 찾을 수 없습니다 - - {availableTables.map((table) => ( - { - onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name); - }} - className="text-xs" - > - - {table.label} - ({table.name}) - - ))} - - - - - -
+ {/* 타겟 테이블 (공통) */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name); + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + +
- {/* 필드 매핑 규칙 */} + {/* 소스 테이블 매핑 그룹 */}
- +

- 소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다. + 여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.

- {!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable ? ( + {!config.action?.dataTransfer?.targetTable ? (
-

먼저 소스 테이블과 타겟 테이블을 선택하세요.

+

먼저 타겟 테이블을 선택하세요.

- ) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? ( + ) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (

- 매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다. + 매핑 그룹이 없습니다. 소스 테이블을 추가하세요.

) : (
- {(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => ( -
- {/* 소스 필드 선택 (Combobox) */} -
- setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} + {/* 소스 테이블 탭 */} +
+ {(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => ( +
+ - - - - - setMappingSourceSearch((prev) => ({ ...prev, [index]: value })) - } - /> - - - 컬럼을 찾을 수 없습니다 - - - {mappingSourceColumns.map((col) => ( - { - const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; - rules[index] = { ...rules[index], sourceField: col.name }; - onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); - setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false })); - }} - className="text-xs" - > - - {col.label} - {col.label !== col.name && ( - ({col.name}) - )} - - ))} - - - - - -
- - - - {/* 타겟 필드 선택 (Combobox) */} -
- setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} + {group.sourceTable + ? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable + : `그룹 ${gIdx + 1}`} + {group.mappingRules?.length > 0 && ( + + {group.mappingRules.length} + + )} + + - - - - - setMappingTargetSearch((prev) => ({ ...prev, [index]: value })) - } - /> - - - 컬럼을 찾을 수 없습니다 - - - {mappingTargetColumns.map((col) => ( - { - const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; - rules[index] = { ...rules[index], targetField: col.name }; - onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); - setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false })); - }} - className="text-xs" - > - - {col.label} - {col.label !== col.name && ( - ({col.name}) - )} - - ))} - - - - - + +
+ ))} +
- -
- ))} + {/* 활성 그룹 편집 영역 */} + {(() => { + const multiMappings = config.action?.dataTransfer?.multiTableMappings || []; + const activeGroup = multiMappings[activeMappingGroupIndex]; + if (!activeGroup) return null; + + const activeSourceTable = activeGroup.sourceTable || ""; + const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || []; + const activeRules: any[] = activeGroup.mappingRules || []; + + const updateGroupField = (field: string, value: any) => { + const mappings = [...multiMappings]; + mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value }; + onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings); + }; + + return ( +
+ {/* 소스 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + updateGroupField("sourceTable", table.name); + if (!mappingSourceColumnsMap[table.name]) { + const cols = await loadMappingColumns(table.name); + setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols })); + } + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+ + {/* 매핑 규칙 목록 */} +
+
+ + +
+ + {!activeSourceTable ? ( +

소스 테이블을 먼저 선택하세요.

+ ) : activeRules.length === 0 ? ( +

매핑 없음 (동일 필드명 자동 매핑)

+ ) : ( + activeRules.map((rule: any, rIdx: number) => { + const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`; + const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`; + return ( +
+
+ + setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open })) + } + > + + + + + + + + 컬럼 없음 + + {activeSourceColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ({col.name})} + + ))} + + + + + +
+ + + +
+ + setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open })) + } + > + + + + + + + + 컬럼 없음 + + {mappingTargetColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], targetField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ({col.name})} + + ))} + + + + + +
+ + +
+ ); + }) + )} +
+
+ ); + })()}
)}
@@ -3567,9 +3712,9 @@ export const ButtonConfigPanel: React.FC = ({
1. 소스 컴포넌트에서 데이터를 선택합니다
- 2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드) + 2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
- 3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다 + 3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다

diff --git a/frontend/components/screen/filters/FormDatePicker.tsx b/frontend/components/screen/filters/FormDatePicker.tsx new file mode 100644 index 00000000..5ab5a1ff --- /dev/null +++ b/frontend/components/screen/filters/FormDatePicker.tsx @@ -0,0 +1,344 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; + +interface FormDatePickerProps { + id?: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + includeTime?: boolean; +} + +export const FormDatePicker: React.FC = ({ + id, + value, + onChange, + placeholder, + disabled = false, + readOnly = false, + includeTime = false, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [timeValue, setTimeValue] = useState("00:00"); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + const parseDate = (val: string): Date | undefined => { + if (!val) return undefined; + try { + const date = new Date(val); + if (isNaN(date.getTime())) return undefined; + return date; + } catch { + return undefined; + } + }; + + const selectedDate = parseDate(value); + + useEffect(() => { + if (isOpen) { + setViewMode("calendar"); + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12); + if (includeTime) { + const hours = String(selectedDate.getHours()).padStart(2, "0"); + const minutes = String(selectedDate.getMinutes()).padStart(2, "0"); + setTimeValue(`${hours}:${minutes}`); + } + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); + setTimeValue("00:00"); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [isOpen]); + + const formatDisplayValue = (): string => { + if (!selectedDate) return ""; + if (includeTime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko }); + return format(selectedDate, "yyyy-MM-dd", { locale: ko }); + }; + + const buildDateStr = (date: Date, time?: string) => { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + if (includeTime) return `${y}-${m}-${d}T${time || timeValue}`; + return `${y}-${m}-${d}`; + }; + + const handleDateClick = (date: Date) => { + onChange(buildDateStr(date)); + if (!includeTime) setIsOpen(false); + }; + + const handleTimeChange = (newTime: string) => { + setTimeValue(newTime); + if (selectedDate) onChange(buildDateStr(selectedDate, newTime)); + }; + + const handleSetToday = () => { + const today = new Date(); + if (includeTime) { + const t = `${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`; + onChange(buildDateStr(today, t)); + } else { + onChange(buildDateStr(today)); + } + setIsOpen(false); + }; + + const handleClear = () => { + onChange(""); + setIsTyping(false); + setIsOpen(false); + }; + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!isOpen) setIsOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + onChange(buildDateStr(date)); + setCurrentMonth(new Date(y, m, 1)); + if (!includeTime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400); + else setIsTyping(false); + } + } + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + const dayOfWeek = monthStart.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; + + return ( + { if (!open) { setIsOpen(false); setIsTyping(false); } }}> + +
{ if (!disabled && !readOnly) setIsOpen(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readOnly && !isOpen) setIsOpen(true); }} + onBlur={() => { if (!isOpen) setIsTyping(false); }} + className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> + {selectedDate && !disabled && !readOnly && !isTyping && ( + { + e.stopPropagation(); + handleClear(); + }} + /> + )} +
+
+ e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} + + {includeTime && viewMode === "calendar" && ( +
+ 시간: + handleTimeChange(e.target.value)} + className="border-input h-8 rounded-md border px-2 text-xs" + /> +
+ )} +
+ + + ); +}; diff --git a/frontend/components/screen/filters/InlineCellDatePicker.tsx b/frontend/components/screen/filters/InlineCellDatePicker.tsx new file mode 100644 index 00000000..f47546b4 --- /dev/null +++ b/frontend/components/screen/filters/InlineCellDatePicker.tsx @@ -0,0 +1,279 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; + +interface InlineCellDatePickerProps { + value: string; + onChange: (value: string) => void; + onSave: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + inputRef?: React.RefObject; +} + +export const InlineCellDatePicker: React.FC = ({ + value, + onChange, + onSave, + onKeyDown, + inputRef, +}) => { + const [isOpen, setIsOpen] = useState(true); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const localInputRef = useRef(null); + const actualInputRef = inputRef || localInputRef; + + const parseDate = (val: string): Date | undefined => { + if (!val) return undefined; + try { + const date = new Date(val); + if (isNaN(date.getTime())) return undefined; + return date; + } catch { + return undefined; + } + }; + + const selectedDate = parseDate(value); + + useEffect(() => { + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + } + }, []); + + const handleDateClick = (date: Date) => { + const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleSetToday = () => { + const today = new Date(); + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleClear = () => { + onChange(""); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleInputChange = (raw: string) => { + onChange(raw); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + } + } + }; + + const handlePopoverClose = (open: boolean) => { + if (!open) { + setIsOpen(false); + onSave(); + } + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + const startDate = new Date(monthStart); + const dayOfWeek = startDate.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; + + return ( + + + handleInputChange(e.target.value)} + onKeyDown={onKeyDown} + onClick={() => setIsOpen(true)} + placeholder="YYYYMMDD" + className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" + /> + + e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} +
+ + + ); +}; diff --git a/frontend/components/screen/filters/ModernDatePicker.tsx b/frontend/components/screen/filters/ModernDatePicker.tsx index 54fdcfed..79f16a41 100644 --- a/frontend/components/screen/filters/ModernDatePicker.tsx +++ b/frontend/components/screen/filters/ModernDatePicker.tsx @@ -34,6 +34,8 @@ export const ModernDatePicker: React.FC = ({ label, value const [isOpen, setIsOpen] = useState(false); const [currentMonth, setCurrentMonth] = useState(new Date()); const [selectingType, setSelectingType] = useState<"from" | "to">("from"); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); // 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장) const [tempValue, setTempValue] = useState(value || {}); @@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC = ({ label, value if (isOpen) { setTempValue(value || {}); setSelectingType("from"); + setViewMode("calendar"); } }, [isOpen, value]); @@ -234,60 +237,150 @@ export const ModernDatePicker: React.FC = ({ label, value
- {/* 월 네비게이션 */} -
- -
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
- -
- - {/* 요일 헤더 */} -
- {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( -
- {day} -
- ))} -
- - {/* 날짜 그리드 */} -
- {allDays.map((date, index) => { - if (!date) { - return
; - } - - const isCurrentMonth = isSameMonth(date, currentMonth); - const isSelected = isRangeStart(date) || isRangeEnd(date); - const isInRangeDate = isInRange(date); - const isTodayDate = isToday(date); - - return ( - - ); - })} -
+
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> + {/* 월 선택 뷰 */} +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> + {/* 월 네비게이션 */} +
+ + + +
+ + {/* 요일 헤더 */} +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ + {/* 날짜 그리드 */} +
+ {allDays.map((date, index) => { + if (!date) { + return
; + } + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = isRangeStart(date) || isRangeEnd(date); + const isInRangeDate = isInRange(date); + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} {/* 선택된 범위 표시 */} {(tempValue.from || tempValue.to) && ( diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index cbec1242..90e44c8e 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -237,6 +237,8 @@ export const V2PropertiesPanel: React.FC = ({ const extraProps: Record = {}; if (componentId === "v2-select") { extraProps.inputType = inputType; + extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; } if (componentId === "v2-list") { extraProps.currentTableName = currentTableName; diff --git a/frontend/components/screen/table-options/GroupingPanel.tsx b/frontend/components/screen/table-options/GroupingPanel.tsx index 0495991d..867448d0 100644 --- a/frontend/components/screen/table-options/GroupingPanel.tsx +++ b/frontend/components/screen/table-options/GroupingPanel.tsx @@ -99,7 +99,7 @@ export const GroupingPanel: React.FC = ({ 전체 해제
-
+
{selectedColumns.map((colName, index) => { const col = table?.columns.find( (c) => c.columnName === colName diff --git a/frontend/components/screen/table-options/TableSettingsModal.tsx b/frontend/components/screen/table-options/TableSettingsModal.tsx index ef07e017..4f9325cb 100644 --- a/frontend/components/screen/table-options/TableSettingsModal.tsx +++ b/frontend/components/screen/table-options/TableSettingsModal.tsx @@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC = ({ isOpen, onClose, onFilters 전체 해제
-
+
{selectedGroupColumns.map((colName, index) => { const col = table?.columns.find((c) => c.columnName === colName); if (!col) return null; diff --git a/frontend/components/screen/widgets/types/DateWidget.tsx b/frontend/components/screen/widgets/types/DateWidget.tsx index edb78df9..3b0f47e2 100644 --- a/frontend/components/screen/widgets/types/DateWidget.tsx +++ b/frontend/components/screen/widgets/types/DateWidget.tsx @@ -1,7 +1,22 @@ "use client"; -import React from "react"; -import { Input } from "@/components/ui/input"; +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; import { WebTypeComponentProps } from "@/lib/registry/types"; import { WidgetComponent, DateTypeConfig } from "@/types/screen"; @@ -10,99 +25,341 @@ export const DateWidget: React.FC = ({ component, value, const { placeholder, required, style } = widget; const config = widget.webTypeConfig as DateTypeConfig | undefined; - // 사용자가 테두리를 설정했는지 확인 const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border); const borderClass = hasCustomBorder ? "!border-0" : ""; - // 날짜 포맷팅 함수 - const formatDateValue = (val: string) => { - if (!val) return ""; + const isDatetime = widget.widgetType === "datetime"; + const [isOpen, setIsOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [timeValue, setTimeValue] = useState("00:00"); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + const parseDate = (val: string | undefined): Date | undefined => { + if (!val) return undefined; try { const date = new Date(val); - if (isNaN(date.getTime())) return val; - - if (widget.widgetType === "datetime") { - return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm - } else { - return date.toISOString().slice(0, 10); // YYYY-MM-DD - } + if (isNaN(date.getTime())) return undefined; + return date; } catch { - return val; + return undefined; } }; - // 날짜 유효성 검증 - const validateDate = (dateStr: string): boolean => { - if (!dateStr) return true; - - const date = new Date(dateStr); - if (isNaN(date.getTime())) return false; - - // 최소/최대 날짜 검증 - if (config?.minDate) { - const minDate = new Date(config.minDate); - if (date < minDate) return false; - } - - if (config?.maxDate) { - const maxDate = new Date(config.maxDate); - if (date > maxDate) return false; - } - - return true; - }; - - // 입력값 처리 - const handleChange = (e: React.ChangeEvent) => { - const inputValue = e.target.value; - - if (validateDate(inputValue)) { - onChange?.(inputValue); - } - }; - - // 웹타입에 따른 input type 결정 - const getInputType = () => { - switch (widget.widgetType) { - case "datetime": - return "datetime-local"; - case "date": - default: - return "date"; - } - }; - - // 기본값 설정 (현재 날짜/시간) - const getDefaultValue = () => { + const getDefaultValue = (): string => { if (config?.defaultValue === "current") { const now = new Date(); - if (widget.widgetType === "datetime") { - return now.toISOString().slice(0, 16); - } else { - return now.toISOString().slice(0, 10); - } + if (isDatetime) return now.toISOString().slice(0, 16); + return now.toISOString().slice(0, 10); } return ""; }; const finalValue = value || getDefaultValue(); + const selectedDate = parseDate(finalValue); + + useEffect(() => { + if (isOpen) { + setViewMode("calendar"); + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + if (isDatetime) { + const hours = String(selectedDate.getHours()).padStart(2, "0"); + const minutes = String(selectedDate.getMinutes()).padStart(2, "0"); + setTimeValue(`${hours}:${minutes}`); + } + } else { + setCurrentMonth(new Date()); + setTimeValue("00:00"); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [isOpen]); + + const formatDisplayValue = (): string => { + if (!selectedDate) return ""; + if (isDatetime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko }); + return format(selectedDate, "yyyy-MM-dd", { locale: ko }); + }; + + const handleDateClick = (date: Date) => { + let dateStr: string; + if (isDatetime) { + const [hours, minutes] = timeValue.split(":").map(Number); + const dt = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours || 0, minutes || 0); + dateStr = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}T${timeValue}`; + } else { + dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + } + onChange?.(dateStr); + if (!isDatetime) { + setIsOpen(false); + } + }; + + const handleTimeChange = (newTime: string) => { + setTimeValue(newTime); + if (selectedDate) { + const dateStr = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}T${newTime}`; + onChange?.(dateStr); + } + }; + + const handleClear = () => { + onChange?.(""); + setIsTyping(false); + setIsOpen(false); + }; + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!isOpen) setIsOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + let dateStr: string; + if (isDatetime) { + dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T${timeValue}`; + } else { + dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + } + onChange?.(dateStr); + setCurrentMonth(new Date(y, m, 1)); + if (!isDatetime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400); + else setIsTyping(false); + } + } + }; + + const handleSetToday = () => { + const today = new Date(); + if (isDatetime) { + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`; + onChange?.(dateStr); + } else { + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + onChange?.(dateStr); + } + setIsOpen(false); + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + const startDate = new Date(monthStart); + const dayOfWeek = startDate.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; return ( - + { if (!v) { setIsOpen(false); setIsTyping(false); } }}> + +
{ if (!readonly) setIsOpen(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!readonly && !isOpen) setIsOpen(true); }} + onBlur={() => { if (!isOpen) setIsTyping(false); }} + className="h-full w-full truncate bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> + {selectedDate && !readonly && !isTyping && ( + { + e.stopPropagation(); + handleClear(); + }} + /> + )} +
+
+ e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} + + {/* datetime 타입: 시간 입력 */} + {isDatetime && viewMode === "calendar" && ( +
+ 시간: + handleTimeChange(e.target.value)} + className="border-input h-8 rounded-md border px-2 text-xs" + /> +
+ )} + +
+ + ); }; DateWidget.displayName = "DateWidget"; - - diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index 3be70840..872e7d57 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useMemo } from "react"; import { apiClient } from "@/lib/api/client"; import { getCategoryValues } from "@/lib/api/tableCategoryValue"; -import { FolderTree, Loader2, Search, X } from "lucide-react"; +import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; interface CategoryColumn { @@ -30,6 +30,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, const [columns, setColumns] = useState([]); const [isLoading, setIsLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); // 검색어로 필터링된 컬럼 목록 const filteredColumns = useMemo(() => { @@ -49,6 +50,44 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, }); }, [columns, searchQuery]); + // 테이블별로 그룹화된 컬럼 목록 + const groupedColumns = useMemo(() => { + const groups: { tableName: string; tableLabel: string; columns: CategoryColumn[] }[] = []; + const groupMap = new Map(); + + for (const col of filteredColumns) { + const key = col.tableName; + if (!groupMap.has(key)) { + groupMap.set(key, []); + } + groupMap.get(key)!.push(col); + } + + for (const [tblName, cols] of groupMap) { + groups.push({ + tableName: tblName, + tableLabel: cols[0]?.tableLabel || tblName, + columns: cols, + }); + } + + return groups; + }, [filteredColumns]); + + // 선택된 컬럼이 있는 그룹을 자동 펼침 + useEffect(() => { + if (!selectedColumn) return; + const tableName = selectedColumn.split(".")[0]; + if (tableName) { + setExpandedGroups((prev) => { + if (prev.has(tableName)) return prev; + const next = new Set(prev); + next.add(tableName); + return next; + }); + } + }, [selectedColumn]); + useEffect(() => { // 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회 loadCategoryColumnsByMenu(); @@ -72,9 +111,10 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, allColumns = response.data; } - // category 타입 컬럼만 필터링 + // category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외) const categoryColumns = allColumns.filter( - (col: any) => col.inputType === "category" || col.input_type === "category" + (col: any) => (col.inputType === "category" || col.input_type === "category") + && !col.categoryRef && !col.category_ref ); console.log("✅ 카테고리 컬럼 필터링 완료:", { @@ -278,35 +318,114 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, )}
-
+
{filteredColumns.length === 0 && searchQuery ? (
'{searchQuery}'에 대한 검색 결과가 없습니다
) : null} - {filteredColumns.map((column) => { - const uniqueKey = `${column.tableName}.${column.columnName}`; - const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교 - return ( -
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} - className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ - isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" - }`} - > -
- -
-

{column.columnLabel || column.columnName}

-

{column.tableLabel || column.tableName}

-
- - {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} - + {groupedColumns.map((group) => { + const isExpanded = expandedGroups.has(group.tableName); + const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0); + const hasSelectedInGroup = group.columns.some( + (c) => selectedColumn === `${c.tableName}.${c.columnName}`, + ); + + // 그룹이 1개뿐이면 드롭다운 없이 바로 표시 + if (groupedColumns.length <= 1) { + return ( +
+ {group.columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + const isSelected = selectedColumn === uniqueKey; + return ( +
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} + className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ + isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" + }`} + > +
+ +
+

{column.columnLabel || column.columnName}

+

{column.tableLabel || column.tableName}

+
+ + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ); + })}
+ ); + } + + return ( +
+ {/* 드롭다운 헤더 */} + + + {/* 펼쳐진 컬럼 목록 */} + {isExpanded && ( +
+ {group.columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + const isSelected = selectedColumn === uniqueKey; + return ( +
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} + className={`cursor-pointer rounded-md px-3 py-1.5 transition-all ${ + isSelected ? "bg-primary/10 font-semibold text-primary" : "hover:bg-muted/50" + }`} + > +
+ + {column.columnLabel || column.columnName} + + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ); + })} +
+ )}
); })} diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index 3c7a9239..2da0647f 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( 2100) return null; + return date; +} + /** * 단일 날짜 선택 컴포넌트 */ const SingleDatePicker = forwardRef< - HTMLButtonElement, + HTMLDivElement, { value?: string; onChange?: (value: string) => void; @@ -83,80 +95,227 @@ const SingleDatePicker = forwardRef< ref, ) => { const [open, setOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + const inputRef = React.useRef(null); const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]); - const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); - const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); - // 표시할 날짜 텍스트 계산 (ISO 형식이면 포맷팅, 아니면 그대로) const displayText = useMemo(() => { if (!value) return ""; - // Date 객체로 변환 후 포맷팅 - if (date && isValid(date)) { - return formatDate(date, dateFormat); - } + if (date && isValid(date)) return formatDate(date, dateFormat); return value; }, [value, date, dateFormat]); - const handleSelect = useCallback( - (selectedDate: Date | undefined) => { - if (selectedDate) { - onChange?.(formatDate(selectedDate, dateFormat)); - setOpen(false); + useEffect(() => { + if (open) { + setViewMode("calendar"); + if (date && isValid(date)) { + setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1)); + setYearRangeStart(Math.floor(date.getFullYear() / 12) * 12); + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); } - }, - [dateFormat, onChange], - ); + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [open]); + + const handleDateClick = useCallback((clickedDate: Date) => { + onChange?.(formatDate(clickedDate, dateFormat)); + setIsTyping(false); + setOpen(false); + }, [dateFormat, onChange]); const handleToday = useCallback(() => { onChange?.(formatDate(new Date(), dateFormat)); + setIsTyping(false); setOpen(false); }, [dateFormat, onChange]); const handleClear = useCallback(() => { onChange?.(""); + setIsTyping(false); setOpen(false); }, [onChange]); + const handleTriggerInput = useCallback((raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!open) setOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const parsed = parseManualDateInput(digitsOnly); + if (parsed) { + onChange?.(formatDate(parsed, dateFormat)); + setCurrentMonth(new Date(parsed.getFullYear(), parsed.getMonth(), 1)); + setTimeout(() => { setIsTyping(false); setOpen(false); }, 400); + } + } + }, [dateFormat, onChange, open]); + + const mStart = startOfMonth(currentMonth); + const mEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: mStart, end: mEnd }); + const dow = mStart.getDay(); + const padding = dow === 0 ? 6 : dow - 1; + const allDays = [...Array(padding).fill(null), ...days]; + return ( - + { if (!v) { setOpen(false); setIsTyping(false); } }}> - + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readonly && !open) setOpen(true); }} + onBlur={() => { if (!open) setIsTyping(false); }} + className={cn( + "h-full w-full bg-transparent text-sm outline-none", + "placeholder:text-muted-foreground disabled:cursor-not-allowed", + !displayText && !isTyping && "text-muted-foreground", + )} + /> +
- - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - return false; - }} - /> -
- {showToday && ( - + )} + +
+ + {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+
+ {["월", "화", "수", "목", "금", "토", "일"].map((d) => ( +
{d}
+ ))} +
+
+ {allDays.map((d, idx) => { + if (!d) return
; + const isCur = isSameMonth(d, currentMonth); + const isSel = date ? isSameDay(d, date) : false; + const isT = isTodayFn(d); + return ( + + ); + })} +
+ )} -
@@ -168,6 +327,149 @@ SingleDatePicker.displayName = "SingleDatePicker"; /** * 날짜 범위 선택 컴포넌트 */ +/** + * 범위 날짜 팝오버 내부 캘린더 (drill-down 지원) + */ +const RangeCalendarPopover: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; + selectedDate?: Date; + onSelect: (date: Date) => void; + label: string; + disabled?: boolean; + readonly?: boolean; + displayValue?: string; +}> = ({ open, onOpenChange, selectedDate, onSelect, label, disabled, readonly, displayValue }) => { + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + useEffect(() => { + if (open) { + setViewMode("calendar"); + if (selectedDate && isValid(selectedDate)) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12); + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [open]); + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const parsed = parseManualDateInput(digitsOnly); + if (parsed) { + setIsTyping(false); + onSelect(parsed); + } + } + }; + + const mStart = startOfMonth(currentMonth); + const mEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: mStart, end: mEnd }); + const dow = mStart.getDay(); + const padding = dow === 0 ? 6 : dow - 1; + const allDays = [...Array(padding).fill(null), ...days]; + + return ( + { if (!v) { setIsTyping(false); } onOpenChange(v); }}> + +
{ if (!disabled && !readonly) onOpenChange(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readonly && !open) onOpenChange(true); }} + onBlur={() => { if (!open) setIsTyping(false); }} + className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> +
+
+ e.preventDefault()}> +
+ {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+
+ {["월", "화", "수", "목", "금", "토", "일"].map((d) => ( +
{d}
+ ))} +
+
+ {allDays.map((d, idx) => { + if (!d) return
; + const isCur = isSameMonth(d, currentMonth); + const isSel = selectedDate ? isSameDay(d, selectedDate) : false; + const isT = isTodayFn(d); + return ( + + ); + })} +
+ + )} +
+ + + ); +}; + const RangeDatePicker = forwardRef< HTMLDivElement, { @@ -186,102 +488,38 @@ const RangeDatePicker = forwardRef< const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]); const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]); - const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); - const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); const handleStartSelect = useCallback( - (date: Date | undefined) => { - if (date) { - const newStart = formatDate(date, dateFormat); - // 시작일이 종료일보다 크면 종료일도 같이 변경 - if (endDate && date > endDate) { - onChange?.([newStart, newStart]); - } else { - onChange?.([newStart, value[1]]); - } - setOpenStart(false); + (date: Date) => { + const newStart = formatDate(date, dateFormat); + if (endDate && date > endDate) { + onChange?.([newStart, newStart]); + } else { + onChange?.([newStart, value[1]]); } + setOpenStart(false); }, [value, dateFormat, endDate, onChange], ); const handleEndSelect = useCallback( - (date: Date | undefined) => { - if (date) { - const newEnd = formatDate(date, dateFormat); - // 종료일이 시작일보다 작으면 시작일도 같이 변경 - if (startDate && date < startDate) { - onChange?.([newEnd, newEnd]); - } else { - onChange?.([value[0], newEnd]); - } - setOpenEnd(false); + (date: Date) => { + const newEnd = formatDate(date, dateFormat); + if (startDate && date < startDate) { + onChange?.([newEnd, newEnd]); + } else { + onChange?.([value[0], newEnd]); } + setOpenEnd(false); }, [value, dateFormat, startDate, onChange], ); return (
- {/* 시작 날짜 */} - - - - - - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - return false; - }} - /> - - - + ~ - - {/* 종료 날짜 */} - - - - - - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - // 시작일보다 이전 날짜는 선택 불가 - if (startDate && date < startDate) return true; - return false; - }} - /> - - +
); }); diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index 734032f3..8b769b56 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -23,6 +23,9 @@ import { import { apiClient } from "@/lib/api/client"; import { allocateNumberingCode } from "@/lib/api/numberingRule"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { DataReceivable } from "@/types/data-transfer"; +import { toast } from "sonner"; // modal-repeater-table 컴포넌트 재사용 import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable"; @@ -38,13 +41,23 @@ declare global { export const V2Repeater: React.FC = ({ config: propConfig, + componentId, parentId, data: initialData, onDataChange, onRowClick, className, formData: parentFormData, + ...restProps }) => { + // ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용) + const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData; + + // componentId 결정: 직접 전달 또는 component 객체에서 추출 + const effectiveComponentId = componentId || (restProps as any).component?.id; + + // ScreenContext 연동 (DataReceiver 등록, Provider 없으면 null) + const screenContext = useScreenContextOptional(); // 설정 병합 const config: V2RepeaterConfig = useMemo( () => ({ @@ -62,9 +75,119 @@ export const V2Repeater: React.FC = ({ const [selectedRows, setSelectedRows] = useState>(new Set()); const [modalOpen, setModalOpen] = useState(false); + // 저장 이벤트 핸들러에서 항상 최신 data를 참조하기 위한 ref + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + // 수정 모드에서 로드된 원본 ID 목록 (삭제 추적용) + const loadedIdsRef = useRef>(new Set()); + // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거 const [autoWidthTrigger, setAutoWidthTrigger] = useState(0); + // ScreenContext DataReceiver 등록 (데이터 전달 액션 수신) + const onDataChangeRef = useRef(onDataChange); + onDataChangeRef.current = onDataChange; + + const handleReceiveData = useCallback( + async (incomingData: any[], configOrMode?: any) => { + console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode }); + + if (!incomingData || incomingData.length === 0) { + toast.warning("전달할 데이터가 없습니다"); + return; + } + + // 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거 + const metaFieldsToStrip = new Set([ + "id", + "created_date", + "updated_date", + "created_by", + "updated_by", + "company_code", + ]); + const normalizedData = incomingData.map((item: any) => { + let raw = item; + if (item && typeof item === "object" && item[0] && typeof item[0] === "object") { + const { 0: originalData, ...additionalFields } = item; + raw = { ...originalData, ...additionalFields }; + } + const cleaned: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (!metaFieldsToStrip.has(key)) { + cleaned[key] = value; + } + } + return cleaned; + }); + + const mode = configOrMode?.mode || configOrMode || "append"; + + // 카테고리 코드 → 라벨 변환 + // allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환 + const codesToResolve = new Set(); + for (const item of normalizedData) { + for (const [key, val] of Object.entries(item)) { + if (key.startsWith("_")) continue; + if (typeof val === "string" && val && !categoryLabelMapRef.current[val]) { + codesToResolve.add(val as string); + } + } + } + + if (codesToResolve.size > 0) { + try { + const resp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(codesToResolve), + }); + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + for (const item of normalizedData) { + for (const key of Object.keys(item)) { + if (key.startsWith("_")) continue; + const val = item[key]; + if (typeof val === "string" && labelData[val]) { + item[key] = labelData[val]; + } + } + } + } + } catch { + // 변환 실패 시 코드 유지 + } + } + + setData((prev) => { + const next = mode === "replace" ? normalizedData : [...prev, ...normalizedData]; + onDataChangeRef.current?.(next); + return next; + }); + + toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`); + }, + [], + ); + + useEffect(() => { + if (screenContext && effectiveComponentId) { + const receiver: DataReceivable = { + componentId: effectiveComponentId, + componentType: "v2-repeater", + receiveData: handleReceiveData, + }; + console.log("📋 [V2Repeater] ScreenContext에 데이터 수신자 등록:", effectiveComponentId); + screenContext.registerDataReceiver(effectiveComponentId, receiver); + + return () => { + screenContext.unregisterDataReceiver(effectiveComponentId); + }; + } + }, [screenContext, effectiveComponentId, handleReceiveData]); + // 소스 테이블 컬럼 라벨 매핑 const [sourceColumnLabels, setSourceColumnLabels] = useState>({}); @@ -73,6 +196,10 @@ export const V2Repeater: React.FC = ({ // 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용) const [categoryLabelMap, setCategoryLabelMap] = useState>({}); + const categoryLabelMapRef = useRef>({}); + useEffect(() => { + categoryLabelMapRef.current = categoryLabelMap; + }, [categoryLabelMap]); // 현재 테이블 컬럼 정보 (inputType 매핑용) const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState>({}); @@ -106,35 +233,54 @@ export const V2Repeater: React.FC = ({ }; }, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]); - // 저장 이벤트 리스너 + // 저장 이벤트 리스너 (dataRef/categoryLabelMapRef를 사용하여 항상 최신 상태 참조) useEffect(() => { const handleSaveEvent = async (event: CustomEvent) => { - // 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용 - const tableName = - config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; - const eventParentId = event.detail?.parentId; - const mainFormData = event.detail?.mainFormData; + const currentData = dataRef.current; + const currentCategoryMap = categoryLabelMapRef.current; - // 🆕 마스터 테이블에서 생성된 ID (FK 연결용) + const configTableName = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + const tableName = configTableName || event.detail?.tableName; + const mainFormData = event.detail?.mainFormData; const masterRecordId = event.detail?.masterRecordId || mainFormData?.id; - if (!tableName || data.length === 0) { + console.log("🔵 [V2Repeater] repeaterSave 이벤트 수신:", { + configTableName, + tableName, + masterRecordId, + dataLength: currentData.length, + foreignKeyColumn: config.foreignKeyColumn, + foreignKeySourceColumn: config.foreignKeySourceColumn, + dataSnapshot: currentData.map((r: any) => ({ id: r.id, item_name: r.item_name })), + }); + toast.info(`[디버그] V2Repeater 이벤트 수신: ${currentData.length}건, table=${tableName}`); + + if (!tableName || currentData.length === 0) { + console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length }); + toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`); + window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); return; } - // V2Repeater 저장 시작 - const saveInfo = { + if (config.foreignKeyColumn) { + const sourceCol = config.foreignKeySourceColumn; + const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined; + if (!hasFkSource && !masterRecordId) { + console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵"); + window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); + return; + } + } + + console.log("V2Repeater 저장 시작", { tableName, - useCustomTable: config.useCustomTable, - mainTableName: config.mainTableName, foreignKeyColumn: config.foreignKeyColumn, masterRecordId, - dataLength: data.length, - }; - console.log("V2Repeater 저장 시작", saveInfo); + dataLength: currentData.length, + }); try { - // 테이블 유효 컬럼 조회 let validColumns: Set = new Set(); try { const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`); @@ -145,13 +291,10 @@ export const V2Repeater: React.FC = ({ console.warn("테이블 컬럼 정보 조회 실패"); } - for (let i = 0; i < data.length; i++) { - const row = data[i]; - - // 내부 필드 제거 + for (let i = 0; i < currentData.length; i++) { + const row = currentData[i]; const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_"))); - // 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함) let mergedData: Record; if (config.useCustomTable && config.mainTableName) { mergedData = { ...cleanRow }; @@ -178,59 +321,83 @@ export const V2Repeater: React.FC = ({ }; } - // 유효하지 않은 컬럼 제거 const filteredData: Record = {}; for (const [key, value] of Object.entries(mergedData)) { if (validColumns.size === 0 || validColumns.has(key)) { - filteredData[key] = value; + if (typeof value === "string" && currentCategoryMap[value]) { + filteredData[key] = currentCategoryMap[value]; + } else { + filteredData[key] = value; + } } } - // 기존 행(id 존재)은 UPDATE, 새 행은 INSERT const rowId = row.id; + console.log(`🔧 [V2Repeater] 행 ${i} 저장:`, { + rowId, + isUpdate: rowId && typeof rowId === "string" && rowId.includes("-"), + filteredDataKeys: Object.keys(filteredData), + }); if (rowId && typeof rowId === "string" && rowId.includes("-")) { - // UUID 형태의 id가 있으면 기존 데이터 → UPDATE const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData; await apiClient.put(`/table-management/tables/${tableName}/edit`, { originalData: { id: rowId }, updatedData: updateFields, }); } else { - // 새 행 → INSERT await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); } } + // 삭제된 행 처리: 원본에는 있었지만 현재 data에 없는 ID를 DELETE + const currentIds = new Set(currentData.map((r) => r.id).filter(Boolean)); + const deletedIds = Array.from(loadedIdsRef.current).filter((id) => !currentIds.has(id)); + if (deletedIds.length > 0) { + console.log("🗑️ [V2Repeater] 삭제할 행:", deletedIds); + try { + await apiClient.delete(`/table-management/tables/${tableName}/delete`, { + data: deletedIds.map((id) => ({ id })), + }); + console.log(`✅ [V2Repeater] ${deletedIds.length}건 삭제 완료`); + } catch (deleteError) { + console.error("❌ [V2Repeater] 삭제 실패:", deleteError); + } + } + + // 저장 완료 후 loadedIdsRef 갱신 + loadedIdsRef.current = new Set(currentData.map((r) => r.id).filter(Boolean)); + + toast.success(`V2Repeater ${currentData.length}건 저장 완료`); } catch (error) { console.error("❌ V2Repeater 저장 실패:", error); - throw error; + toast.error(`V2Repeater 저장 실패: ${error}`); + } finally { + window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); } }; - // V2 EventBus 구독 const unsubscribe = v2EventBus.subscribe( V2_EVENTS.REPEATER_SAVE, async (payload) => { - const tableName = + const configTableName = config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; - if (payload.tableName === tableName) { + if (!configTableName || payload.tableName === configTableName) { await handleSaveEvent({ detail: payload } as CustomEvent); } }, - { componentId: `v2-repeater-${config.dataSource?.tableName}` }, + { componentId: `v2-repeater-${config.dataSource?.tableName || "same-table"}` }, ); - // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) window.addEventListener("repeaterSave" as any, handleSaveEvent); return () => { unsubscribe(); window.removeEventListener("repeaterSave" as any, handleSaveEvent); }; }, [ - data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, + config.foreignKeySourceColumn, parentId, ]); @@ -261,7 +428,10 @@ export const V2Repeater: React.FC = ({ { page: 1, size: 1000, - search: { [config.foreignKeyColumn]: fkValue }, + dataFilter: { + enabled: true, + filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }], + }, autoFilter: true, } ); @@ -298,7 +468,6 @@ export const V2Repeater: React.FC = ({ }); // 각 행에 소스 테이블의 표시 데이터 병합 - // RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함 rows.forEach((row: any) => { const sourceRecord = sourceMap.get(String(row[fkColumn])); if (sourceRecord) { @@ -316,12 +485,50 @@ export const V2Repeater: React.FC = ({ } } + // DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환 + const codesToResolve = new Set(); + for (const row of rows) { + for (const val of Object.values(row)) { + if (typeof val === "string" && val.startsWith("CATEGORY_")) { + codesToResolve.add(val); + } + } + } + + if (codesToResolve.size > 0) { + try { + const labelResp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(codesToResolve), + }); + if (labelResp.data?.success && labelResp.data.data) { + const labelData = labelResp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + for (const row of rows) { + for (const key of Object.keys(row)) { + if (key.startsWith("_")) continue; + const val = row[key]; + if (typeof val === "string" && labelData[val]) { + row[key] = labelData[val]; + } + } + } + } + } catch { + // 라벨 변환 실패 시 코드 유지 + } + } + + // 원본 ID 목록 기록 (삭제 추적용) + const ids = rows.map((r: any) => r.id).filter(Boolean); + loadedIdsRef.current = new Set(ids); + console.log("📋 [V2Repeater] 원본 ID 기록:", ids); + setData(rows); dataLoadedRef.current = true; if (onDataChange) onDataChange(rows); } } catch (error) { - console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error); + console.error("[V2Repeater] 기존 데이터 로드 실패:", error); } }; @@ -343,16 +550,28 @@ export const V2Repeater: React.FC = ({ if (!tableName) return; try { - const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); - const columns = response.data?.data?.columns || response.data?.columns || response.data || []; + const [colResponse, typeResponse] = await Promise.all([ + apiClient.get(`/table-management/tables/${tableName}/columns`), + apiClient.get(`/table-management/tables/${tableName}/web-types`), + ]); + const columns = colResponse.data?.data?.columns || colResponse.data?.columns || colResponse.data || []; + const inputTypes = typeResponse.data?.data || []; + + // inputType/categoryRef 매핑 생성 + const typeMap: Record = {}; + inputTypes.forEach((t: any) => { + typeMap[t.columnName] = t; + }); const columnMap: Record = {}; columns.forEach((col: any) => { const name = col.columnName || col.column_name || col.name; + const typeInfo = typeMap[name]; columnMap[name] = { - inputType: col.inputType || col.input_type || col.webType || "text", + inputType: typeInfo?.inputType || col.inputType || col.input_type || col.webType || "text", displayName: col.displayName || col.display_name || col.label || name, detailSettings: col.detailSettings || col.detail_settings, + categoryRef: typeInfo?.categoryRef || null, }; }); setCurrentTableColumnInfo(columnMap); @@ -484,14 +703,18 @@ export const V2Repeater: React.FC = ({ else if (inputType === "code") type = "select"; else if (inputType === "category") type = "category"; // 🆕 카테고리 타입 - // 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식) - // category 타입인 경우 현재 테이블명과 컬럼명을 조합 + // 카테고리 참조 ID 결정 + // DB의 category_ref 설정 우선, 없으면 자기 테이블.컬럼명 사용 let categoryRef: string | undefined; if (inputType === "category") { - // 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용 - const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName; - if (tableName) { - categoryRef = `${tableName}.${col.key}`; + const dbCategoryRef = colInfo?.detailSettings?.categoryRef || colInfo?.categoryRef; + if (dbCategoryRef) { + categoryRef = dbCategoryRef; + } else { + const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName; + if (tableName) { + categoryRef = `${tableName}.${col.key}`; + } } } @@ -509,55 +732,79 @@ export const V2Repeater: React.FC = ({ }); }, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]); - // 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용) + // 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지 + // repeaterColumns의 resolved type 사용 (config + DB 메타데이터 모두 반영) + const allCategoryColumns = useMemo(() => { + const fromRepeater = repeaterColumns + .filter((col) => col.type === "category") + .map((col) => col.field.replace(/^_display_/, "")); + const merged = new Set([...sourceCategoryColumns, ...fromRepeater]); + return Array.from(merged); + }, [sourceCategoryColumns, repeaterColumns]); + + // CATEGORY_ 코드 배열을 받아 라벨을 일괄 조회하는 함수 + const fetchCategoryLabels = useCallback(async (codes: string[]) => { + if (codes.length === 0) return; + try { + const response = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: codes, + }); + if (response.data?.success && response.data.data) { + setCategoryLabelMap((prev) => ({ ...prev, ...response.data.data })); + } + } catch (error) { + console.error("카테고리 라벨 조회 실패:", error); + } + }, []); + + // parentFormData(마스터 행)에서 카테고리 코드를 미리 로드 + // fromMainForm autoFill에서 참조할 마스터 필드의 라벨을 사전에 확보 useEffect(() => { - const loadCategoryLabels = async () => { - if (sourceCategoryColumns.length === 0 || data.length === 0) { - return; - } + if (!parentFormData) return; + const codes: string[] = []; - // 데이터에서 카테고리 컬럼의 모든 고유 코드 수집 - const allCodes = new Set(); - for (const row of data) { - for (const col of sourceCategoryColumns) { - // _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인 - const val = row[`_display_${col}`] || row[col]; - if (val && typeof val === "string") { - const codes = val - .split(",") - .map((c: string) => c.trim()) - .filter(Boolean); - for (const code of codes) { - if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) { - allCodes.add(code); - } - } - } + // fromMainForm autoFill의 sourceField 값 중 카테고리 컬럼에 해당하는 것만 수집 + for (const col of config.columns) { + if (col.autoFill?.type === "fromMainForm" && col.autoFill.sourceField) { + const val = parentFormData[col.autoFill.sourceField]; + if (typeof val === "string" && val && !categoryLabelMap[val]) { + codes.push(val); } } - - if (allCodes.size === 0) { - return; - } - - try { - const response = await apiClient.post("/table-categories/labels-by-codes", { - valueCodes: Array.from(allCodes), - }); - - if (response.data?.success && response.data.data) { - setCategoryLabelMap((prev) => ({ - ...prev, - ...response.data.data, - })); + // receiveFromParent 패턴 + if ((col as any).receiveFromParent) { + const parentField = (col as any).parentFieldName || col.key; + const val = parentFormData[parentField]; + if (typeof val === "string" && val && !categoryLabelMap[val]) { + codes.push(val); } - } catch (error) { - console.error("카테고리 라벨 조회 실패:", error); } - }; + } - loadCategoryLabels(); - }, [data, sourceCategoryColumns]); + if (codes.length > 0) { + fetchCategoryLabels(codes); + } + }, [parentFormData, config.columns, fetchCategoryLabels]); + + // 데이터 변경 시 카테고리 라벨 로드 + useEffect(() => { + if (data.length === 0) return; + + const allCodes = new Set(); + + for (const row of data) { + for (const col of allCategoryColumns) { + const val = row[`_display_${col}`] || row[col]; + if (val && typeof val === "string") { + val.split(",").map((c: string) => c.trim()).filter(Boolean).forEach((code: string) => { + if (!categoryLabelMap[code]) allCodes.add(code); + }); + } + } + } + + fetchCategoryLabels(Array.from(allCodes)); + }, [data, allCategoryColumns, fetchCategoryLabels]); // 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능) const applyCalculationRules = useCallback( @@ -674,18 +921,32 @@ export const V2Repeater: React.FC = ({ case "fromMainForm": if (col.autoFill.sourceField && mainFormData) { - return mainFormData[col.autoFill.sourceField]; + const rawValue = mainFormData[col.autoFill.sourceField]; + // categoryLabelMap에 매핑이 있으면 라벨로 변환 (접두사 무관) + if (typeof rawValue === "string" && categoryLabelMap[rawValue]) { + return categoryLabelMap[rawValue]; + } + return rawValue; } return ""; 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; } }, - [], + [categoryLabelMap], ); // 🆕 채번 API 호출 (비동기) @@ -707,7 +968,121 @@ 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) { + let sourceValue = item[(col as any).sourceKey || col.key]; + + // 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반) + if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) { + sourceValue = categoryLabelMap[sourceValue]; + } + + 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; + }); + + // 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관) + const categoryColSet = new Set(allCategoryColumns); + const codesToResolve = new Set(); + for (const row of newRows) { + for (const col of config.columns) { + const val = row[col.key] || row[`_display_${col.key}`]; + if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) { + if (!categoryLabelMap[val]) { + codesToResolve.add(val); + } + } + } + } + + if (codesToResolve.size > 0) { + apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(codesToResolve), + }).then((resp) => { + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + const convertedRows = newRows.map((row) => { + const updated = { ...row }; + for (const col of config.columns) { + const val = updated[col.key]; + if (typeof val === "string" && labelData[val]) { + updated[col.key] = labelData[val]; + } + const dispKey = `_display_${col.key}`; + const dispVal = updated[dispKey]; + if (typeof dispVal === "string" && labelData[dispVal]) { + updated[dispKey] = labelData[dispVal]; + } + } + return updated; + }); + setData(convertedRows); + onDataChange?.(convertedRows); + } + }).catch(() => {}); + } + + 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); @@ -715,11 +1090,10 @@ export const V2Repeater: React.FC = ({ const newRow: any = { _id: `new_${Date.now()}` }; const currentRowCount = data.length; - // 먼저 동기적 자동 입력 값 적용 + // 동기적 자동 입력 값 적용 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); } else if (autoValue !== undefined) { newRow[col.key] = autoValue; @@ -728,10 +1102,51 @@ export const V2Repeater: React.FC = ({ } } + // fromMainForm 등으로 넘어온 카테고리 코드 → 라벨 변환 + // allCategoryColumns에 해당하는 컬럼이거나 categoryLabelMap에 매핑이 있으면 변환 + const categoryColSet = new Set(allCategoryColumns); + const unresolvedCodes: string[] = []; + for (const col of config.columns) { + const val = newRow[col.key]; + if (typeof val !== "string" || !val) continue; + + // 이 컬럼이 카테고리 타입이거나, fromMainForm으로 가져온 값인 경우 + const isCategoryCol = categoryColSet.has(col.key); + const isFromMainForm = col.autoFill?.type === "fromMainForm"; + + if (isCategoryCol || isFromMainForm) { + if (categoryLabelMap[val]) { + newRow[col.key] = categoryLabelMap[val]; + } else { + unresolvedCodes.push(val); + } + } + } + + if (unresolvedCodes.length > 0) { + try { + const resp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: unresolvedCodes, + }); + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + for (const col of config.columns) { + const val = newRow[col.key]; + if (typeof val === "string" && labelData[val]) { + newRow[col.key] = labelData[val]; + } + } + } + } catch { + // 변환 실패 시 코드 유지 + } + } + const newData = [...data, newRow]; handleDataChange(newData); } - }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode]); + }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData, categoryLabelMap, allCategoryColumns]); // 모달에서 항목 선택 - 비동기로 변경 const handleSelectItems = useCallback( @@ -756,11 +1171,15 @@ export const V2Repeater: React.FC = ({ // 모든 컬럼 처리 (순서대로) for (const col of config.columns) { if (col.isSourceDisplay) { - // 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용) - row[`_display_${col.key}`] = item[col.key] || ""; + let displayVal = item[col.key] || ""; + // 카테고리 컬럼이면 코드→라벨 변환 (접두사 무관) + if (typeof displayVal === "string" && categoryLabelMap[displayVal]) { + displayVal = categoryLabelMap[displayVal]; + } + row[`_display_${col.key}`] = displayVal; } 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); @@ -777,6 +1196,43 @@ export const V2Repeater: React.FC = ({ }), ); + // 카테고리/fromMainForm 컬럼에서 미해결 코드 수집 및 변환 + const categoryColSet = new Set(allCategoryColumns); + const unresolvedCodes = new Set(); + for (const row of newRows) { + for (const col of config.columns) { + const val = row[col.key]; + if (typeof val !== "string" || !val) continue; + const isCategoryCol = categoryColSet.has(col.key); + const isFromMainForm = col.autoFill?.type === "fromMainForm"; + if ((isCategoryCol || isFromMainForm) && !categoryLabelMap[val]) { + unresolvedCodes.add(val); + } + } + } + + if (unresolvedCodes.size > 0) { + try { + const resp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(unresolvedCodes), + }); + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + for (const row of newRows) { + for (const col of config.columns) { + const val = row[col.key]; + if (typeof val === "string" && labelData[val]) { + row[col.key] = labelData[val]; + } + } + } + } + } catch { + // 변환 실패 시 코드 유지 + } + } + const newData = [...data, ...newRows]; handleDataChange(newData); setModalOpen(false); @@ -789,6 +1245,9 @@ export const V2Repeater: React.FC = ({ handleDataChange, generateAutoFillValueSync, generateNumberingCode, + parentFormData, + categoryLabelMap, + allCategoryColumns, ], ); @@ -801,9 +1260,6 @@ export const V2Repeater: React.FC = ({ }, [config.columns]); // 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환 - const dataRef = useRef(data); - dataRef.current = data; - useEffect(() => { const handleBeforeFormSave = async (event: Event) => { const customEvent = event as CustomEvent; @@ -1032,7 +1488,7 @@ export const V2Repeater: React.FC = ({ selectedRows={selectedRows} onSelectionChange={setSelectedRows} equalizeWidthsTrigger={autoWidthTrigger} - categoryColumns={sourceCategoryColumns} + categoryColumns={allCategoryColumns} categoryLabelMap={categoryLabelMap} />
diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index a7769227..e7dbfd86 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -23,7 +23,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -import { V2SelectProps, SelectOption } from "@/types/v2-components"; +import { V2SelectProps, SelectOption, V2SelectFilter } from "@/types/v2-components"; import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react"; import { apiClient } from "@/lib/api/client"; import V2FormContext from "./V2FormContext"; @@ -655,6 +655,7 @@ export const V2Select = forwardRef( const labelColumn = config.labelColumn; const apiEndpoint = config.apiEndpoint; const staticOptions = config.options; + const configFilters = config.filters; // 계층 코드 연쇄 선택 관련 const hierarchical = config.hierarchical; @@ -662,6 +663,54 @@ export const V2Select = forwardRef( // FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null) const formContext = useContext(V2FormContext); + + /** + * 필터 조건을 API 전달용 JSON으로 변환 + * field/user 타입은 런타임 값으로 치환 + */ + const resolvedFiltersJson = useMemo(() => { + if (!configFilters || configFilters.length === 0) return undefined; + + const resolved: Array<{ column: string; operator: string; value: unknown }> = []; + + for (const f of configFilters) { + const vt = f.valueType || "static"; + + // isNull/isNotNull은 값 불필요 + if (f.operator === "isNull" || f.operator === "isNotNull") { + resolved.push({ column: f.column, operator: f.operator, value: null }); + continue; + } + + let resolvedValue: unknown = f.value; + + if (vt === "field" && f.fieldRef) { + // 다른 폼 필드 참조 + if (formContext) { + resolvedValue = formContext.getValue(f.fieldRef); + } else { + const fd = (props as any).formData; + resolvedValue = fd?.[f.fieldRef]; + } + // 참조 필드 값이 비어있으면 이 필터 건너뜀 + if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") continue; + } else if (vt === "user" && f.userField) { + // 로그인 사용자 정보 참조 (props에서 가져옴) + const userMap: Record = { + companyCode: (props as any).companyCode, + userId: (props as any).userId, + deptCode: (props as any).deptCode, + userName: (props as any).userName, + }; + resolvedValue = userMap[f.userField]; + if (!resolvedValue) continue; + } + + resolved.push({ column: f.column, operator: f.operator, value: resolvedValue }); + } + + return resolved.length > 0 ? JSON.stringify(resolved) : undefined; + }, [configFilters, formContext, props]); // 부모 필드의 값 계산 const parentValue = useMemo(() => { @@ -684,6 +733,13 @@ export const V2Select = forwardRef( } }, [parentValue, hierarchical, source]); + // 필터 조건이 변경되면 옵션 다시 로드 + useEffect(() => { + if (resolvedFiltersJson !== undefined) { + setOptionsLoaded(false); + } + }, [resolvedFiltersJson]); + useEffect(() => { // 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외) if (optionsLoaded && source !== "static") { @@ -731,11 +787,13 @@ export const V2Select = forwardRef( } } else if (source === "db" && table) { // DB 테이블에서 로드 + const dbParams: Record = { + value: valueColumn || "id", + label: labelColumn || "name", + }; + if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson; const response = await apiClient.get(`/entity/${table}/options`, { - params: { - value: valueColumn || "id", - label: labelColumn || "name", - }, + params: dbParams, }); const data = response.data; if (data.success && data.data) { @@ -745,8 +803,10 @@ export const V2Select = forwardRef( // 엔티티(참조 테이블)에서 로드 const valueCol = entityValueColumn || "id"; const labelCol = entityLabelColumn || "name"; + const entityParams: Record = { value: valueCol, label: labelCol }; + if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson; const response = await apiClient.get(`/entity/${entityTable}/options`, { - params: { value: valueCol, label: labelCol }, + params: entityParams, }); const data = response.data; if (data.success && data.data) { @@ -790,11 +850,13 @@ export const V2Select = forwardRef( } } else if (source === "select" || source === "distinct") { // 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회 - // tableName, columnName은 props에서 가져옴 - // 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀 const isValidColumnName = columnName && !columnName.startsWith("comp_"); if (tableName && isValidColumnName) { - const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`); + const distinctParams: Record = {}; + if (resolvedFiltersJson) distinctParams.filters = resolvedFiltersJson; + const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`, { + params: distinctParams, + }); const data = response.data; if (data.success && data.data) { fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ @@ -818,7 +880,7 @@ export const V2Select = forwardRef( }; loadOptions(); - }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); + }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]); // 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응) const resolvedValue = useMemo(() => { diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 71eef64c..1f89ae12 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) @@ -1213,13 +1214,21 @@ export const V2RepeaterConfigPanel: React.FC = ({ )} - {/* 편집 가능 체크박스 */} + {/* 편집 가능 토글 */} {!col.isSourceDisplay && ( - updateColumnProp(col.key, "editable", !!checked)} - title="편집 가능" - /> + )}
)} + + {/* 부모채번+순번 설정 */} + {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/components/v2/config-panels/V2SelectConfigPanel.tsx b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx index f808ecf1..66ebb369 100644 --- a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx @@ -5,56 +5,401 @@ * 통합 선택 컴포넌트의 세부 설정을 관리합니다. */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; -import { Plus, Trash2, Loader2 } from "lucide-react"; +import { Plus, Trash2, Loader2, Filter } from "lucide-react"; import { apiClient } from "@/lib/api/client"; +import type { V2SelectFilter } from "@/types/v2-components"; interface ColumnOption { columnName: string; columnLabel: string; } +interface CategoryValueOption { + valueCode: string; + valueLabel: string; +} + +const OPERATOR_OPTIONS = [ + { value: "=", label: "같음 (=)" }, + { value: "!=", label: "다름 (!=)" }, + { value: ">", label: "초과 (>)" }, + { value: "<", label: "미만 (<)" }, + { value: ">=", label: "이상 (>=)" }, + { value: "<=", label: "이하 (<=)" }, + { value: "in", label: "포함 (IN)" }, + { value: "notIn", label: "미포함 (NOT IN)" }, + { value: "like", label: "유사 (LIKE)" }, + { value: "isNull", label: "NULL" }, + { value: "isNotNull", label: "NOT NULL" }, +] as const; + +const VALUE_TYPE_OPTIONS = [ + { value: "static", label: "고정값" }, + { value: "field", label: "폼 필드 참조" }, + { value: "user", label: "로그인 사용자" }, +] as const; + +const USER_FIELD_OPTIONS = [ + { value: "companyCode", label: "회사코드" }, + { value: "userId", label: "사용자ID" }, + { value: "deptCode", label: "부서코드" }, + { value: "userName", label: "사용자명" }, +] as const; + +/** + * 필터 조건 설정 서브 컴포넌트 + */ +const FilterConditionsSection: React.FC<{ + filters: V2SelectFilter[]; + columns: ColumnOption[]; + loadingColumns: boolean; + targetTable: string; + onFiltersChange: (filters: V2SelectFilter[]) => void; +}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => { + + const addFilter = () => { + onFiltersChange([ + ...filters, + { column: "", operator: "=", valueType: "static", value: "" }, + ]); + }; + + const updateFilter = (index: number, patch: Partial) => { + const updated = [...filters]; + updated[index] = { ...updated[index], ...patch }; + + // valueType 변경 시 관련 필드 초기화 + if (patch.valueType) { + if (patch.valueType === "static") { + updated[index].fieldRef = undefined; + updated[index].userField = undefined; + } else if (patch.valueType === "field") { + updated[index].value = undefined; + updated[index].userField = undefined; + } else if (patch.valueType === "user") { + updated[index].value = undefined; + updated[index].fieldRef = undefined; + } + } + + // isNull/isNotNull 연산자는 값 불필요 + if (patch.operator === "isNull" || patch.operator === "isNotNull") { + updated[index].value = undefined; + updated[index].fieldRef = undefined; + updated[index].userField = undefined; + updated[index].valueType = "static"; + } + + onFiltersChange(updated); + }; + + const removeFilter = (index: number) => { + onFiltersChange(filters.filter((_, i) => i !== index)); + }; + + const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull"; + + return ( +
+
+
+ + +
+ +
+ +

+ {targetTable} 테이블에서 옵션을 불러올 때 적용할 조건 +

+ + {loadingColumns && ( +
+ + 컬럼 목록 로딩 중... +
+ )} + + {filters.length === 0 && ( +

+ 필터 조건이 없습니다 +

+ )} + +
+ {filters.map((filter, index) => ( +
+ {/* 행 1: 컬럼 + 연산자 + 삭제 */} +
+ {/* 컬럼 선택 */} + + + {/* 연산자 선택 */} + + + {/* 삭제 버튼 */} + +
+ + {/* 행 2: 값 유형 + 값 입력 (isNull/isNotNull 제외) */} + {needsValue(filter.operator) && ( +
+ {/* 값 유형 */} + + + {/* 값 입력 영역 */} + {(filter.valueType || "static") === "static" && ( + updateFilter(index, { value: e.target.value })} + placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"} + className="h-7 flex-1 text-[11px]" + /> + )} + + {filter.valueType === "field" && ( + updateFilter(index, { fieldRef: e.target.value })} + placeholder="참조할 필드명 (columnName)" + className="h-7 flex-1 text-[11px]" + /> + )} + + {filter.valueType === "user" && ( + + )} +
+ )} +
+ ))} +
+
+ ); +}; + interface V2SelectConfigPanelProps { config: Record; onChange: (config: Record) => void; - /** 컬럼의 inputType (entity 타입인 경우에만 엔티티 소스 표시) */ + /** 컬럼의 inputType (entity/category 타입 확인용) */ inputType?: string; + /** 현재 테이블명 (카테고리 값 조회용) */ + tableName?: string; + /** 현재 컬럼명 (카테고리 값 조회용) */ + columnName?: string; } -export const V2SelectConfigPanel: React.FC = ({ config, onChange, inputType }) => { - // 엔티티 타입인지 확인 +export const V2SelectConfigPanel: React.FC = ({ + config, + onChange, + inputType, + tableName, + columnName, +}) => { const isEntityType = inputType === "entity"; - // 엔티티 테이블의 컬럼 목록 + const isCategoryType = inputType === "category"; + const [entityColumns, setEntityColumns] = useState([]); const [loadingColumns, setLoadingColumns] = useState(false); - // 설정 업데이트 핸들러 + // 카테고리 값 목록 + const [categoryValues, setCategoryValues] = useState([]); + const [loadingCategoryValues, setLoadingCategoryValues] = useState(false); + + // 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼) + const [filterColumns, setFilterColumns] = useState([]); + const [loadingFilterColumns, setLoadingFilterColumns] = useState(false); + const updateConfig = (field: string, value: any) => { onChange({ ...config, [field]: value }); }; + // 필터 대상 테이블 결정 + const filterTargetTable = useMemo(() => { + const src = config.source || "static"; + if (src === "entity") return config.entityTable; + if (src === "db") return config.table; + if (src === "distinct" || src === "select") return tableName; + return null; + }, [config.source, config.entityTable, config.table, tableName]); + + // 필터 대상 테이블의 컬럼 로드 + useEffect(() => { + if (!filterTargetTable) { + setFilterColumns([]); + return; + } + + const loadFilterColumns = async () => { + setLoadingFilterColumns(true); + try { + const response = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`); + const data = response.data.data || response.data; + const columns = data.columns || data || []; + setFilterColumns( + columns.map((col: any) => ({ + columnName: col.columnName || col.column_name || col.name, + columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name, + })) + ); + } catch { + setFilterColumns([]); + } finally { + setLoadingFilterColumns(false); + } + }; + + loadFilterColumns(); + }, [filterTargetTable]); + + // 카테고리 타입이면 source를 자동으로 category로 설정 + useEffect(() => { + if (isCategoryType && config.source !== "category") { + onChange({ ...config, source: "category" }); + } + }, [isCategoryType]); + + // 카테고리 값 로드 + const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => { + if (!catTable || !catColumn) { + setCategoryValues([]); + return; + } + + setLoadingCategoryValues(true); + try { + const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`); + const data = response.data; + if (data.success && data.data) { + const flattenTree = (items: any[], depth: number = 0): CategoryValueOption[] => { + const result: CategoryValueOption[] = []; + for (const item of items) { + result.push({ + valueCode: item.valueCode, + valueLabel: depth > 0 ? `${" ".repeat(depth)}${item.valueLabel}` : item.valueLabel, + }); + if (item.children && item.children.length > 0) { + result.push(...flattenTree(item.children, depth + 1)); + } + } + return result; + }; + setCategoryValues(flattenTree(data.data)); + } + } catch (error) { + console.error("카테고리 값 조회 실패:", error); + setCategoryValues([]); + } finally { + setLoadingCategoryValues(false); + } + }, []); + + // 카테고리 소스일 때 값 로드 + useEffect(() => { + if (config.source === "category") { + const catTable = config.categoryTable || tableName; + const catColumn = config.categoryColumn || columnName; + if (catTable && catColumn) { + loadCategoryValues(catTable, catColumn); + } + } + }, [config.source, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]); + // 엔티티 테이블 변경 시 컬럼 목록 조회 - const loadEntityColumns = useCallback(async (tableName: string) => { - if (!tableName) { + const loadEntityColumns = useCallback(async (tblName: string) => { + if (!tblName) { setEntityColumns([]); return; } setLoadingColumns(true); try { - const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=500`); + const response = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`); const data = response.data.data || response.data; const columns = data.columns || data || []; const columnOptions: ColumnOption[] = columns.map((col: any) => { const name = col.columnName || col.column_name || col.name; - // displayName 우선 사용 const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name; return { @@ -72,7 +417,6 @@ export const V2SelectConfigPanel: React.FC = ({ config } }, []); - // 엔티티 테이블이 변경되면 컬럼 목록 로드 useEffect(() => { if (config.source === "entity" && config.entityTable) { loadEntityColumns(config.entityTable); @@ -98,6 +442,9 @@ export const V2SelectConfigPanel: React.FC = ({ config updateConfig("options", newOptions); }; + // 현재 source 결정 (카테고리 타입이면 강제 category) + const effectiveSource = isCategoryType ? "category" : config.source || "static"; + return (
{/* 선택 모드 */} @@ -125,21 +472,102 @@ export const V2SelectConfigPanel: React.FC = ({ config {/* 데이터 소스 */}
- + {isCategoryType ? ( +
+ 카테고리 (자동 설정) +
+ ) : ( + + )}
+ {/* 카테고리 설정 */} + {effectiveSource === "category" && ( +
+
+ +
+
+
+

테이블

+

{config.categoryTable || tableName || "-"}

+
+
+

컬럼

+

{config.categoryColumn || columnName || "-"}

+
+
+
+
+ + {/* 카테고리 값 로딩 중 */} + {loadingCategoryValues && ( +
+ + 카테고리 값 로딩 중... +
+ )} + + {/* 카테고리 값 목록 표시 */} + {categoryValues.length > 0 && ( +
+ +
+ {categoryValues.map((cv) => ( +
+ {cv.valueCode} + {cv.valueLabel} +
+ ))} +
+
+ )} + + {/* 기본값 설정 */} + {categoryValues.length > 0 && ( +
+ + +

화면 로드 시 자동 선택될 카테고리 값

+
+ )} + + {/* 카테고리 값 없음 안내 */} + {!loadingCategoryValues && categoryValues.length === 0 && ( +

+ 카테고리 값이 없습니다. 테이블 카테고리 관리에서 값을 추가해주세요. +

+ )} +
+ )} + {/* 정적 옵션 관리 */} - {(config.source || "static") === "static" && ( + {effectiveSource === "static" && (
@@ -199,8 +627,8 @@ export const V2SelectConfigPanel: React.FC = ({ config
)} - {/* 공통 코드 설정 - 테이블 타입 관리에서 설정되므로 정보만 표시 */} - {config.source === "code" && ( + {/* 공통 코드 설정 */} + {effectiveSource === "code" && (
{config.codeGroup ? ( @@ -212,7 +640,7 @@ export const V2SelectConfigPanel: React.FC = ({ config )} {/* 엔티티(참조 테이블) 설정 */} - {config.source === "entity" && ( + {effectiveSource === "entity" && (
@@ -228,7 +656,6 @@ export const V2SelectConfigPanel: React.FC = ({ config

- {/* 컬럼 로딩 중 표시 */} {loadingColumns && (
@@ -236,7 +663,6 @@ export const V2SelectConfigPanel: React.FC = ({ config
)} - {/* 컬럼 선택 - 테이블이 설정되어 있고 컬럼 목록이 있는 경우 Select로 표시 */}
@@ -296,18 +722,17 @@ export const V2SelectConfigPanel: React.FC = ({ config
- {/* 컬럼이 없는 경우 안내 */} {config.entityTable && !loadingColumns && entityColumns.length === 0 && (

테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.

)} - {/* 자동 채움 안내 */} {config.entityTable && entityColumns.length > 0 && (

- 같은 폼에 참조 테이블({config.entityTable})의 컬럼이 배치되어 있으면, 엔티티 선택 시 해당 필드가 자동으로 채워집니다. + 같은 폼에 참조 테이블({config.entityTable})의 컬럼이 배치되어 있으면, 엔티티 선택 시 해당 필드가 자동으로 + 채워집니다.

)} @@ -368,6 +793,20 @@ export const V2SelectConfigPanel: React.FC = ({ config />
)} + + {/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */} + {effectiveSource !== "static" && filterTargetTable && ( + <> + + updateConfig("filters", filters)} + /> + + )}
); }; diff --git a/frontend/contexts/ScreenContext.tsx b/frontend/contexts/ScreenContext.tsx index 5e9bb2f1..8a57f9cb 100644 --- a/frontend/contexts/ScreenContext.tsx +++ b/frontend/contexts/ScreenContext.tsx @@ -6,17 +6,28 @@ "use client"; import React, { createContext, useContext, useCallback, useRef, useState } from "react"; -import type { DataProvidable, DataReceivable } from "@/types/data-transfer"; +import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; import { logger } from "@/lib/utils/logger"; import type { SplitPanelPosition } from "@/contexts/SplitPanelContext"; +/** + * 대기 중인 데이터 전달 항목 + * 타겟 컴포넌트가 아직 마운트되지 않은 경우 (조건부 레이어 등) 버퍼에 저장 + */ +export interface PendingTransfer { + targetComponentId: string; + data: any[]; + config: DataReceiverConfig; + timestamp: number; + targetLayerId?: string; +} + interface ScreenContextValue { screenId?: number; tableName?: string; - menuObjid?: number; // 메뉴 OBJID (카테고리 값 조회 시 필요) - splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right) + menuObjid?: number; + splitPanelPosition?: SplitPanelPosition; - // 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장) formData: Record; updateFormData: (fieldName: string, value: any) => void; @@ -33,6 +44,11 @@ interface ScreenContextValue { // 모든 컴포넌트 조회 getAllDataProviders: () => Map; getAllDataReceivers: () => Map; + + // 대기 중인 데이터 전달 (레이어 내부 컴포넌트 미마운트 대응) + addPendingTransfer: (transfer: PendingTransfer) => void; + getPendingTransfer: (componentId: string) => PendingTransfer | undefined; + clearPendingTransfer: (componentId: string) => void; } const ScreenContext = createContext(null); @@ -57,11 +73,10 @@ export function ScreenContextProvider({ }: ScreenContextProviderProps) { const dataProvidersRef = useRef>(new Map()); const dataReceiversRef = useRef>(new Map()); + const pendingTransfersRef = useRef>(new Map()); - // 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장) const [formData, setFormData] = useState>({}); - // 🆕 폼 데이터 업데이트 함수 const updateFormData = useCallback((fieldName: string, value: any) => { setFormData((prev) => { const updated = { ...prev, [fieldName]: value }; @@ -87,6 +102,25 @@ export function ScreenContextProvider({ const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => { dataReceiversRef.current.set(componentId, receiver); logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType }); + + // 대기 중인 데이터 전달이 있으면 즉시 수신 처리 + const pending = pendingTransfersRef.current.get(componentId); + if (pending) { + logger.info("대기 중인 데이터 전달 자동 수신", { + componentId, + dataCount: pending.data.length, + waitedMs: Date.now() - pending.timestamp, + }); + receiver + .receiveData(pending.data, pending.config) + .then(() => { + pendingTransfersRef.current.delete(componentId); + logger.info("대기 데이터 전달 완료", { componentId }); + }) + .catch((err) => { + logger.error("대기 데이터 전달 실패", { componentId, error: err }); + }); + } }, []); const unregisterDataReceiver = useCallback((componentId: string) => { @@ -110,7 +144,24 @@ export function ScreenContextProvider({ return new Map(dataReceiversRef.current); }, []); - // 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지) + const addPendingTransfer = useCallback((transfer: PendingTransfer) => { + pendingTransfersRef.current.set(transfer.targetComponentId, transfer); + logger.info("데이터 전달 대기열 추가", { + targetComponentId: transfer.targetComponentId, + dataCount: transfer.data.length, + targetLayerId: transfer.targetLayerId, + }); + }, []); + + const getPendingTransfer = useCallback((componentId: string) => { + return pendingTransfersRef.current.get(componentId); + }, []); + + const clearPendingTransfer = useCallback((componentId: string) => { + pendingTransfersRef.current.delete(componentId); + logger.debug("대기 데이터 전달 클리어", { componentId }); + }, []); + const value = React.useMemo( () => ({ screenId, @@ -127,6 +178,9 @@ export function ScreenContextProvider({ getDataReceiver, getAllDataProviders, getAllDataReceivers, + addPendingTransfer, + getPendingTransfer, + clearPendingTransfer, }), [ screenId, @@ -143,6 +197,9 @@ export function ScreenContextProvider({ getDataReceiver, getAllDataProviders, getAllDataReceivers, + addPendingTransfer, + getPendingTransfer, + clearPendingTransfer, ], ); diff --git a/frontend/hooks/pop/executePopAction.ts b/frontend/hooks/pop/executePopAction.ts new file mode 100644 index 00000000..5800125f --- /dev/null +++ b/frontend/hooks/pop/executePopAction.ts @@ -0,0 +1,199 @@ +/** + * executePopAction - POP 액션 실행 순수 함수 + * + * pop-button, pop-string-list 등 여러 컴포넌트에서 재사용 가능한 + * 액션 실행 코어 로직. React 훅에 의존하지 않음. + * + * 사용처: + * - usePopAction 훅 (pop-button용 래퍼) + * - pop-string-list 카드 버튼 (직접 호출) + * - 향후 pop-table 행 액션 등 + */ + +import type { ButtonMainAction } from "@/lib/registry/pop-components/pop-button"; +import { apiClient } from "@/lib/api/client"; +import { dataApi } from "@/lib/api/data"; + +// ======================================== +// 타입 정의 +// ======================================== + +/** 액션 실행 결과 */ +export interface ActionResult { + success: boolean; + data?: unknown; + error?: string; +} + +/** 이벤트 발행 함수 시그니처 (usePopEvent의 publish와 동일) */ +type PublishFn = (eventName: string, payload?: unknown) => void; + +/** executePopAction 옵션 */ +interface ExecuteOptions { + /** 필드 매핑 (소스 컬럼명 → 타겟 컬럼명) */ + fieldMapping?: Record; + /** 화면 ID (이벤트 발행 시 사용) */ + screenId?: string; + /** 이벤트 발행 함수 (순수 함수이므로 외부에서 주입) */ + publish?: PublishFn; +} + +// ======================================== +// 내부 헬퍼 +// ======================================== + +/** + * 필드 매핑 적용 + * 소스 데이터의 컬럼명을 타겟 테이블 컬럼명으로 변환 + */ +function applyFieldMapping( + rowData: Record, + mapping?: Record +): Record { + if (!mapping || Object.keys(mapping).length === 0) { + return { ...rowData }; + } + + const result: Record = {}; + for (const [sourceKey, value] of Object.entries(rowData)) { + // 매핑이 있으면 타겟 키로 변환, 없으면 원본 키 유지 + const targetKey = mapping[sourceKey] || sourceKey; + result[targetKey] = value; + } + return result; +} + +/** + * rowData에서 PK 추출 + * id > pk 순서로 시도, 없으면 rowData 전체를 복합키로 사용 + */ +function extractPrimaryKey( + rowData: Record +): string | number | Record { + if (rowData.id != null) return rowData.id as string | number; + if (rowData.pk != null) return rowData.pk as string | number; + // 복합키: rowData 전체를 Record로 전달 (dataApi.deleteRecord가 object 지원) + return rowData as Record; +} + +// ======================================== +// 메인 함수 +// ======================================== + +/** + * POP 액션 실행 (순수 함수) + * + * @param action - 버튼 메인 액션 설정 + * @param rowData - 대상 행 데이터 (리스트 컴포넌트에서 전달) + * @param options - 필드 매핑, screenId, publish 함수 + * @returns 실행 결과 + */ +export async function executePopAction( + action: ButtonMainAction, + rowData?: Record, + options?: ExecuteOptions +): Promise { + const { fieldMapping, publish } = options || {}; + + try { + switch (action.type) { + // ── 저장 ── + case "save": { + if (!action.targetTable) { + return { success: false, error: "저장 대상 테이블이 설정되지 않았습니다." }; + } + const data = rowData + ? applyFieldMapping(rowData, fieldMapping) + : {}; + const result = await dataApi.createRecord(action.targetTable, data); + return { success: !!result?.success, data: result?.data, error: result?.message }; + } + + // ── 삭제 ── + case "delete": { + if (!action.targetTable) { + return { success: false, error: "삭제 대상 테이블이 설정되지 않았습니다." }; + } + if (!rowData) { + return { success: false, error: "삭제할 데이터가 없습니다." }; + } + const mappedData = applyFieldMapping(rowData, fieldMapping); + const pk = extractPrimaryKey(mappedData); + const result = await dataApi.deleteRecord(action.targetTable, pk); + return { success: !!result?.success, error: result?.message }; + } + + // ── API 호출 ── + case "api": { + if (!action.apiEndpoint) { + return { success: false, error: "API 엔드포인트가 설정되지 않았습니다." }; + } + const body = rowData + ? applyFieldMapping(rowData, fieldMapping) + : undefined; + const method = (action.apiMethod || "POST").toUpperCase(); + + let response; + switch (method) { + case "GET": + response = await apiClient.get(action.apiEndpoint, { params: body }); + break; + case "POST": + response = await apiClient.post(action.apiEndpoint, body); + break; + case "PUT": + response = await apiClient.put(action.apiEndpoint, body); + break; + case "DELETE": + response = await apiClient.delete(action.apiEndpoint, { data: body }); + break; + default: + response = await apiClient.post(action.apiEndpoint, body); + } + + const resData = response?.data; + return { + success: resData?.success !== false, + data: resData?.data ?? resData, + }; + } + + // ── 모달 열기 ── + case "modal": { + if (!publish) { + return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." }; + } + publish("__pop_modal_open__", { + modalId: action.modalScreenId, + title: action.modalTitle, + mode: action.modalMode, + items: action.modalItems, + rowData, + }); + return { success: true }; + } + + // ── 이벤트 발행 ── + case "event": { + if (!publish) { + return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." }; + } + if (!action.eventName) { + return { success: false, error: "이벤트 이름이 설정되지 않았습니다." }; + } + publish(action.eventName, { + ...(action.eventPayload || {}), + row: rowData, + }); + return { success: true }; + } + + default: + return { success: false, error: `알 수 없는 액션 타입: ${action.type}` }; + } + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."; + return { success: false, error: message }; + } +} diff --git a/frontend/hooks/pop/index.ts b/frontend/hooks/pop/index.ts new file mode 100644 index 00000000..7ae7e953 --- /dev/null +++ b/frontend/hooks/pop/index.ts @@ -0,0 +1,30 @@ +/** + * POP 공통 훅 배럴 파일 + * + * 사용법: import { usePopEvent, useDataSource } from "@/hooks/pop"; + */ + +// 이벤트 통신 훅 +export { usePopEvent, cleanupScreen } from "./usePopEvent"; + +// 데이터 CRUD 훅 +export { useDataSource } from "./useDataSource"; +export type { MutationResult, DataSourceResult } from "./useDataSource"; + +// 액션 실행 순수 함수 +export { executePopAction } from "./executePopAction"; +export type { ActionResult } from "./executePopAction"; + +// 액션 실행 React 훅 +export { usePopAction } from "./usePopAction"; +export type { PendingConfirmState } from "./usePopAction"; + +// 연결 해석기 +export { useConnectionResolver } from "./useConnectionResolver"; + +// 장바구니 동기화 훅 +export { useCartSync } from "./useCartSync"; +export type { UseCartSyncReturn } from "./useCartSync"; + +// SQL 빌더 유틸 (고급 사용 시) +export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder"; diff --git a/frontend/hooks/pop/popSqlBuilder.ts b/frontend/hooks/pop/popSqlBuilder.ts new file mode 100644 index 00000000..bd9fd599 --- /dev/null +++ b/frontend/hooks/pop/popSqlBuilder.ts @@ -0,0 +1,195 @@ +/** + * POP 공통 SQL 빌더 + * + * DataSourceConfig를 SQL 문자열로 변환하는 순수 유틸리티. + * 원본: pop-dashboard/utils/dataFetcher.ts에서 추출 (로직 동일). + * + * 대시보드(dataFetcher.ts)는 기존 코드를 그대로 유지하고, + * 새 컴포넌트(useDataSource 등)는 이 파일을 사용한다. + * 향후 대시보드 교체 시 dataFetcher.ts가 이 파일을 import하도록 변경 예정. + * + * 보안: + * - escapeSQL: SQL 인젝션 방지 (문자열 이스케이프) + * - sanitizeIdentifier: 테이블명/컬럼명에서 위험 문자 제거 + */ + +import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types"; + +// ===== SQL 값 이스케이프 ===== + +/** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */ +function escapeSQL(value: unknown): string { + if (value === null || value === undefined) return "NULL"; + if (typeof value === "number") return String(value); + if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; + // 문자열: 작은따옴표 이스케이프 + const str = String(value).replace(/'/g, "''"); + return `'${str}'`; +} + +// ===== 식별자 검증 (테이블명, 컬럼명) ===== + +/** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */ +function sanitizeIdentifier(name: string): string { + // 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용 + return name.replace(/[^a-zA-Z0-9_.]/g, ""); +} + +// ===== 설정 완료 여부 검증 ===== + +/** + * DataSourceConfig의 필수값이 모두 채워졌는지 검증 + * 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는 + * SQL을 생성하지 않도록 사전 차단 + * + * @returns null이면 유효, 문자열이면 미완료 사유 + */ +export function validateDataSourceConfig(config: DataSourceConfig): string | null { + // 테이블명 필수 + if (!config.tableName || !config.tableName.trim()) { + return "테이블이 선택되지 않았습니다"; + } + + // 집계 함수가 설정되었으면 대상 컬럼도 필수 + // (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능) + if (config.aggregation) { + const aggType = config.aggregation.type?.toLowerCase(); + const aggCol = config.aggregation.column?.trim(); + if (aggType !== "count" && !aggCol) { + return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`; + } + } + + // 조인이 있으면 조인 조건 필수 + if (config.joins?.length) { + for (const join of config.joins) { + if (!join.targetTable?.trim()) { + return "조인 대상 테이블이 선택되지 않았습니다"; + } + if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) { + return "조인 조건 컬럼이 설정되지 않았습니다"; + } + } + } + + return null; +} + +// ===== 필터 조건 SQL 생성 ===== + +/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */ +function buildWhereClause(filters: DataSourceFilter[]): string { + // 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어) + const validFilters = filters.filter((f) => f.column?.trim()); + if (!validFilters.length) return ""; + + const conditions = validFilters.map((f) => { + const col = sanitizeIdentifier(f.column); + + switch (f.operator) { + case "between": { + const arr = Array.isArray(f.value) ? f.value : [f.value, f.value]; + return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`; + } + case "in": { + const arr = Array.isArray(f.value) ? f.value : [f.value]; + const vals = arr.map(escapeSQL).join(", "); + return `${col} IN (${vals})`; + } + case "like": + return `${col} LIKE ${escapeSQL(f.value)}`; + default: + return `${col} ${f.operator} ${escapeSQL(f.value)}`; + } + }); + + return `WHERE ${conditions.join(" AND ")}`; +} + +// ===== 집계 SQL 빌더 ===== + +/** + * DataSourceConfig를 SELECT SQL로 변환 + * + * @param config - 데이터 소스 설정 + * @returns SQL 문자열 + */ +export function buildAggregationSQL(config: DataSourceConfig): string { + const tableName = sanitizeIdentifier(config.tableName); + + // SELECT 절 + let selectClause: string; + if (config.aggregation) { + const aggType = config.aggregation.type.toUpperCase(); + const aggCol = config.aggregation.column?.trim() + ? sanitizeIdentifier(config.aggregation.column) + : ""; + + // COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수 + if (!aggCol) { + selectClause = aggType === "COUNT" + ? "COUNT(*) as value" + : `${aggType}(${tableName}.*) as value`; + } else { + selectClause = `${aggType}(${aggCol}) as value`; + } + + // GROUP BY가 있으면 해당 컬럼도 SELECT에 포함 + if (config.aggregation.groupBy?.length) { + const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", "); + selectClause = `${groupCols}, ${selectClause}`; + } + } else { + selectClause = "*"; + } + + // FROM 절 (조인 포함 - 조건이 완전한 조인만 적용) + let fromClause = tableName; + if (config.joins?.length) { + for (const join of config.joins) { + // 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어) + if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) { + continue; + } + const joinTable = sanitizeIdentifier(join.targetTable); + const joinType = join.joinType.toUpperCase(); + const srcCol = sanitizeIdentifier(join.on.sourceColumn); + const tgtCol = sanitizeIdentifier(join.on.targetColumn); + fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`; + } + } + + // WHERE 절 + const whereClause = config.filters?.length + ? buildWhereClause(config.filters) + : ""; + + // GROUP BY 절 + let groupByClause = ""; + if (config.aggregation?.groupBy?.length) { + groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`; + } + + // ORDER BY 절 + let orderByClause = ""; + if (config.sort?.length) { + const sortCols = config.sort + .map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`) + .join(", "); + orderByClause = `ORDER BY ${sortCols}`; + } + + // LIMIT 절 + const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : ""; + + return [ + `SELECT ${selectClause}`, + `FROM ${fromClause}`, + whereClause, + groupByClause, + orderByClause, + limitClause, + ] + .filter(Boolean) + .join(" "); +} diff --git a/frontend/hooks/pop/useCartSync.ts b/frontend/hooks/pop/useCartSync.ts new file mode 100644 index 00000000..e3b76ed5 --- /dev/null +++ b/frontend/hooks/pop/useCartSync.ts @@ -0,0 +1,338 @@ +/** + * useCartSync - 장바구니 DB 동기화 훅 + * + * DB(cart_items 테이블) <-> 로컬 상태를 동기화하고 변경사항(dirty)을 감지한다. + * + * 동작 방식: + * 1. 마운트 시 DB에서 해당 screen_id + user_id의 장바구니를 로드 + * 2. addItem/removeItem/updateItem은 로컬 상태만 변경 (DB 미반영, dirty 상태) + * 3. saveToDb 호출 시 로컬 상태를 DB에 일괄 반영 (추가/수정/삭제) + * 4. isDirty = 로컬 상태와 DB 마지막 로드 상태의 차이 존재 여부 + * + * 사용 예시: + * ```typescript + * const cart = useCartSync("SCR-001", "item_info"); + * + * // 품목 추가 (로컬만, DB 미반영) + * cart.addItem({ row, quantity: 10 }, "D1710008"); + * + * // DB 저장 (pop-icon 확인 모달에서 호출) + * await cart.saveToDb(); + * ``` + */ + +"use client"; + +import { useState, useCallback, useRef, useEffect } from "react"; +import { dataApi } from "@/lib/api/data"; +import type { + CartItem, + CartItemWithId, + CartSyncStatus, + CartItemStatus, +} from "@/lib/registry/pop-components/types"; + +// ===== 반환 타입 ===== + +export interface UseCartSyncReturn { + cartItems: CartItemWithId[]; + savedItems: CartItemWithId[]; + syncStatus: CartSyncStatus; + cartCount: number; + isDirty: boolean; + loading: boolean; + + addItem: (item: CartItem, rowKey: string) => void; + removeItem: (rowKey: string) => void; + updateItemQuantity: (rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => void; + isItemInCart: (rowKey: string) => boolean; + getCartItem: (rowKey: string) => CartItemWithId | undefined; + + saveToDb: (selectedColumns?: string[]) => Promise; + loadFromDb: () => Promise; + resetToSaved: () => void; +} + +// ===== DB 행 -> CartItemWithId 변환 ===== + +function dbRowToCartItem(dbRow: Record): CartItemWithId { + let rowData: Record = {}; + try { + const raw = dbRow.row_data; + if (typeof raw === "string" && raw.trim()) { + rowData = JSON.parse(raw); + } else if (typeof raw === "object" && raw !== null) { + rowData = raw as Record; + } + } catch { + rowData = {}; + } + + let packageEntries: CartItem["packageEntries"] | undefined; + try { + const raw = dbRow.package_entries; + if (typeof raw === "string" && raw.trim()) { + packageEntries = JSON.parse(raw); + } else if (Array.isArray(raw)) { + packageEntries = raw; + } + } catch { + packageEntries = undefined; + } + + return { + row: rowData, + quantity: Number(dbRow.quantity) || 0, + packageUnit: (dbRow.package_unit as string) || undefined, + packageEntries, + cartId: (dbRow.id as string) || undefined, + sourceTable: (dbRow.source_table as string) || "", + rowKey: (dbRow.row_key as string) || "", + status: ((dbRow.status as string) || "in_cart") as CartItemStatus, + _origin: "db", + memo: (dbRow.memo as string) || undefined, + }; +} + +// ===== CartItemWithId -> DB 저장용 레코드 변환 ===== + +function cartItemToDbRecord( + item: CartItemWithId, + screenId: string, + cartType: string = "pop", + selectedColumns?: string[], +): Record { + // selectedColumns가 있으면 해당 컬럼만 추출, 없으면 전체 저장 + const rowData = + selectedColumns && selectedColumns.length > 0 + ? Object.fromEntries( + Object.entries(item.row).filter(([k]) => selectedColumns.includes(k)), + ) + : item.row; + + return { + cart_type: cartType, + screen_id: screenId, + source_table: item.sourceTable, + row_key: item.rowKey, + row_data: JSON.stringify(rowData), + quantity: String(item.quantity), + unit: "", + package_unit: item.packageUnit || "", + package_entries: item.packageEntries ? JSON.stringify(item.packageEntries) : "", + status: item.status, + memo: item.memo || "", + }; +} + +// ===== dirty check: 두 배열의 내용이 동일한지 비교 ===== + +function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean { + if (a.length !== b.length) return false; + + const serialize = (items: CartItemWithId[]) => + items + .map((item) => `${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}`) + .sort() + .join("|"); + + return serialize(a) === serialize(b); +} + +// ===== 훅 본체 ===== + +export function useCartSync( + screenId: string, + sourceTable: string, + cartType?: string, +): UseCartSyncReturn { + const [cartItems, setCartItems] = useState([]); + const [savedItems, setSavedItems] = useState([]); + const [syncStatus, setSyncStatus] = useState("clean"); + const [loading, setLoading] = useState(false); + + const screenIdRef = useRef(screenId); + const sourceTableRef = useRef(sourceTable); + const cartTypeRef = useRef(cartType || "pop"); + screenIdRef.current = screenId; + sourceTableRef.current = sourceTable; + cartTypeRef.current = cartType || "pop"; + + // ----- DB에서 장바구니 로드 ----- + const loadFromDb = useCallback(async () => { + if (!screenId) return; + setLoading(true); + try { + const result = await dataApi.getTableData("cart_items", { + size: 500, + filters: { + screen_id: screenId, + cart_type: cartTypeRef.current, + status: "in_cart", + }, + }); + + const items = (result.data || []).map(dbRowToCartItem); + setSavedItems(items); + setCartItems(items); + setSyncStatus("clean"); + } catch (err) { + console.error("[useCartSync] DB 로드 실패:", err); + } finally { + setLoading(false); + } + }, [screenId]); + + // 마운트 시 자동 로드 + useEffect(() => { + loadFromDb(); + }, [loadFromDb]); + + // ----- dirty 상태 계산 ----- + const isDirty = !areItemsEqual(cartItems, savedItems); + + // isDirty 변경 시 syncStatus 자동 갱신 + useEffect(() => { + if (syncStatus !== "saving") { + setSyncStatus(isDirty ? "dirty" : "clean"); + } + }, [isDirty, syncStatus]); + + // ----- 로컬 조작 (DB 미반영) ----- + + const addItem = useCallback( + (item: CartItem, rowKey: string) => { + setCartItems((prev) => { + const exists = prev.find((i) => i.rowKey === rowKey); + if (exists) { + return prev.map((i) => + i.rowKey === rowKey + ? { ...i, quantity: item.quantity, packageUnit: item.packageUnit, packageEntries: item.packageEntries, row: item.row } + : i, + ); + } + const newItem: CartItemWithId = { + ...item, + cartId: undefined, + sourceTable: sourceTableRef.current, + rowKey, + status: "in_cart", + _origin: "local", + }; + return [...prev, newItem]; + }); + }, + [], + ); + + const removeItem = useCallback((rowKey: string) => { + setCartItems((prev) => prev.filter((i) => i.rowKey !== rowKey)); + }, []); + + const updateItemQuantity = useCallback( + (rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => { + setCartItems((prev) => + prev.map((i) => + i.rowKey === rowKey + ? { + ...i, + quantity, + ...(packageUnit !== undefined && { packageUnit }), + ...(packageEntries !== undefined && { packageEntries }), + } + : i, + ), + ); + }, + [], + ); + + const isItemInCart = useCallback( + (rowKey: string) => cartItems.some((i) => i.rowKey === rowKey), + [cartItems], + ); + + const getCartItem = useCallback( + (rowKey: string) => cartItems.find((i) => i.rowKey === rowKey), + [cartItems], + ); + + // ----- DB 저장 (일괄) ----- + const saveToDb = useCallback(async (selectedColumns?: string[]): Promise => { + setSyncStatus("saving"); + try { + const currentScreenId = screenIdRef.current; + + // 삭제 대상: savedItems에 있지만 cartItems에 없는 것 + const cartRowKeys = new Set(cartItems.map((i) => i.rowKey)); + const toDelete = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey)); + + // 추가 대상: cartItems에 있지만 cartId가 없는 것 (로컬에서 추가됨) + const toCreate = cartItems.filter((c) => !c.cartId); + + // 수정 대상: 양쪽 다 존재하고 cartId 있으면서 내용이 다른 것 + const savedMap = new Map(savedItems.map((s) => [s.rowKey, s])); + const toUpdate = cartItems.filter((c) => { + if (!c.cartId) return false; + const saved = savedMap.get(c.rowKey); + if (!saved) return false; + return ( + c.quantity !== saved.quantity || + c.packageUnit !== saved.packageUnit || + c.status !== saved.status + ); + }); + + const promises: Promise[] = []; + + for (const item of toDelete) { + promises.push(dataApi.deleteRecord("cart_items", item.cartId!)); + } + + const currentCartType = cartTypeRef.current; + + for (const item of toCreate) { + const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns); + promises.push(dataApi.createRecord("cart_items", record)); + } + + for (const item of toUpdate) { + const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns); + promises.push(dataApi.updateRecord("cart_items", item.cartId!, record)); + } + + await Promise.all(promises); + + // 저장 후 DB에서 다시 로드하여 cartId 등을 최신화 + await loadFromDb(); + return true; + } catch (err) { + console.error("[useCartSync] DB 저장 실패:", err); + setSyncStatus("dirty"); + return false; + } + }, [cartItems, savedItems, loadFromDb]); + + // ----- 로컬 변경 취소 ----- + const resetToSaved = useCallback(() => { + setCartItems(savedItems); + setSyncStatus("clean"); + }, [savedItems]); + + return { + cartItems, + savedItems, + syncStatus, + cartCount: cartItems.length, + isDirty, + loading, + addItem, + removeItem, + updateItemQuantity, + isItemInCart, + getCartItem, + saveToDb, + loadFromDb, + resetToSaved, + }; +} diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts new file mode 100644 index 00000000..3c20acc2 --- /dev/null +++ b/frontend/hooks/pop/useConnectionResolver.ts @@ -0,0 +1,70 @@ +/** + * useConnectionResolver - 런타임 컴포넌트 연결 해석기 + * + * PopViewerWithModals에서 사용. + * layout.dataFlow.connections를 읽고, 소스 컴포넌트의 __comp_output__ 이벤트를 + * 타겟 컴포넌트의 __comp_input__ 이벤트로 자동 변환/중계한다. + * + * 이벤트 규칙: + * 소스: __comp_output__${sourceComponentId}__${outputKey} + * 타겟: __comp_input__${targetComponentId}__${inputKey} + */ + +import { useEffect, useRef } from "react"; +import { usePopEvent } from "./usePopEvent"; +import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout"; + +interface UseConnectionResolverOptions { + screenId: string; + connections: PopDataConnection[]; +} + +export function useConnectionResolver({ + screenId, + connections, +}: UseConnectionResolverOptions): void { + const { publish, subscribe } = usePopEvent(screenId); + + // 연결 목록을 ref로 저장하여 콜백 안정성 확보 + const connectionsRef = useRef(connections); + connectionsRef.current = connections; + + useEffect(() => { + if (!connections || connections.length === 0) return; + + const unsubscribers: (() => void)[] = []; + + // 소스별로 그룹핑하여 구독 생성 + const sourceGroups = new Map(); + for (const conn of connections) { + const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`; + const existing = sourceGroups.get(sourceEvent) || []; + existing.push(conn); + sourceGroups.set(sourceEvent, existing); + } + + for (const [sourceEvent, conns] of sourceGroups) { + const unsub = subscribe(sourceEvent, (payload: unknown) => { + for (const conn of conns) { + const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`; + + // 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId } + const enrichedPayload = { + value: payload, + filterConfig: conn.filterConfig, + _connectionId: conn.id, + }; + + publish(targetEvent, enrichedPayload); + } + }); + unsubscribers.push(unsub); + } + + return () => { + for (const unsub of unsubscribers) { + unsub(); + } + }; + }, [screenId, connections, subscribe, publish]); +} diff --git a/frontend/hooks/pop/useDataSource.ts b/frontend/hooks/pop/useDataSource.ts new file mode 100644 index 00000000..23bd9f93 --- /dev/null +++ b/frontend/hooks/pop/useDataSource.ts @@ -0,0 +1,383 @@ +/** + * useDataSource - POP 컴포넌트용 데이터 CRUD 통합 훅 + * + * DataSourceConfig를 받아서 자동으로 적절한 API를 선택하여 데이터를 조회/생성/수정/삭제한다. + * + * 조회 분기: + * - aggregation 또는 joins가 있으면 → SQL 빌더 + executeQuery (대시보드와 동일) + * - 그 외 → dataApi.getTableData (단순 테이블 조회) + * + * CRUD: + * - save: dataApi.createRecord + * - update: dataApi.updateRecord + * - remove: dataApi.deleteRecord + * + * 사용 패턴: + * ```typescript + * // 집계 조회 (대시보드용) + * const { data, loading } = useDataSource({ + * tableName: "sales_order", + * aggregation: { type: "sum", column: "amount", groupBy: ["category"] }, + * refreshInterval: 30, + * }); + * + * // 단순 목록 조회 (테이블용) + * const { data, refetch } = useDataSource({ + * tableName: "purchase_order", + * sort: [{ column: "created_at", direction: "desc" }], + * limit: 20, + * }); + * + * // 저장/삭제 (버튼용) + * const { save, remove } = useDataSource({ tableName: "inbound_record" }); + * await save({ supplier_id: "SUP-001", quantity: 50 }); + * ``` + */ + +import { useState, useCallback, useEffect, useRef } from "react"; +import { apiClient } from "@/lib/api/client"; +import { dashboardApi } from "@/lib/api/dashboard"; +import { dataApi } from "@/lib/api/data"; +import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types"; +import { validateDataSourceConfig, buildAggregationSQL } from "./popSqlBuilder"; + +// ===== 타입 정의 ===== + +/** 조회 결과 */ +export interface DataSourceResult { + /** 데이터 행 배열 */ + rows: Record[]; + /** 단일 집계 값 (aggregation 시) 또는 전체 행 수 */ + value: number; + /** 전체 행 수 (페이징용) */ + total: number; +} + +/** CRUD 작업 결과 */ +export interface MutationResult { + success: boolean; + data?: unknown; + error?: string; +} + +/** refetch 시 전달할 오버라이드 필터 */ +interface OverrideOptions { + filters?: Record; +} + +// ===== 내부: 집계/조인 조회 ===== + +/** + * 집계 또는 조인이 포함된 DataSourceConfig를 SQL로 변환하여 실행 + * dataFetcher.ts의 fetchAggregatedData와 동일한 로직 + */ +async function fetchWithSqlBuilder( + config: DataSourceConfig +): Promise { + const sql = buildAggregationSQL(config); + + // API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백 + let queryResult: { columns: string[]; rows: Record[] }; + try { + // 1차: apiClient (axios 기반, 인증/세션 안정적) + const response = await apiClient.post("/dashboards/execute-query", { query: sql }); + if (response.data?.success && response.data?.data) { + queryResult = response.data.data; + } else { + throw new Error(response.data?.message || "쿼리 실행 실패"); + } + } catch { + // 2차: dashboardApi (fetch 기반, 폴백) + queryResult = await dashboardApi.executeQuery(sql); + } + + if (queryResult.rows.length === 0) { + return { rows: [], value: 0, total: 0 }; + } + + // PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨 → 숫자 변환 + const processedRows = queryResult.rows.map((row) => { + const converted: Record = { ...row }; + for (const key of Object.keys(converted)) { + const val = converted[key]; + if (typeof val === "string" && val !== "" && !isNaN(Number(val))) { + converted[key] = Number(val); + } + } + return converted; + }); + + // 첫 번째 행의 value 컬럼 추출 + const firstRow = processedRows[0]; + const numericValue = parseFloat( + String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0) + ); + + return { + rows: processedRows, + value: Number.isFinite(numericValue) ? numericValue : 0, + total: processedRows.length, + }; +} + +// ===== 내부: 단순 테이블 조회 ===== + +/** + * aggregation/joins 없는 단순 테이블 조회 + * dataApi.getTableData 래핑 + */ +async function fetchSimpleTable( + config: DataSourceConfig, + overrideFilters?: Record +): Promise { + // config.filters를 Record 형태로 변환 + const baseFilters: Record = {}; + if (config.filters?.length) { + for (const f of config.filters) { + if (f.column?.trim()) { + baseFilters[f.column] = f.value; + } + } + } + + // overrideFilters가 있으면 병합 (같은 키는 override가 덮어씀) + const mergedFilters = overrideFilters + ? { ...baseFilters, ...overrideFilters } + : baseFilters; + + const tableResult = await dataApi.getTableData(config.tableName, { + page: 1, + size: config.limit ?? 100, + sortBy: config.sort?.[0]?.column, + sortOrder: config.sort?.[0]?.direction, + filters: Object.keys(mergedFilters).length > 0 ? mergedFilters : undefined, + }); + + return { + rows: tableResult.data, + value: tableResult.total ?? tableResult.data.length, + total: tableResult.total ?? tableResult.data.length, + }; +} + +// ===== 내부: overrideFilters를 DataSourceFilter 배열에 병합 ===== + +/** + * 기존 config에 overrideFilters를 병합한 새 config 생성 + * 같은 column이 있으면 override 값으로 대체 + */ +function mergeFilters( + config: DataSourceConfig, + overrideFilters?: Record +): DataSourceConfig { + if (!overrideFilters || Object.keys(overrideFilters).length === 0) { + return config; + } + + // 기존 filters에서 override 대상이 아닌 것만 유지 + const overrideColumns = new Set(Object.keys(overrideFilters)); + const existingFilters: DataSourceFilter[] = (config.filters ?? []).filter( + (f) => !overrideColumns.has(f.column) + ); + + // override를 DataSourceFilter로 변환하여 추가 + const newFilters: DataSourceFilter[] = Object.entries(overrideFilters).map( + ([column, value]) => ({ + column, + operator: "=" as const, + value, + }) + ); + + return { + ...config, + filters: [...existingFilters, ...newFilters], + }; +} + +// ===== 메인 훅 ===== + +/** + * POP 컴포넌트용 데이터 CRUD 통합 훅 + * + * @param config - DataSourceConfig (tableName 필수) + * @returns data, loading, error, refetch, save, update, remove + */ +export function useDataSource(config: DataSourceConfig) { + const [data, setData] = useState({ + rows: [], + value: 0, + total: 0, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // config를 ref로 저장 (콜백 안정성) + const configRef = useRef(config); + configRef.current = config; + + // 자동 새로고침 타이머 + const refreshTimerRef = useRef | null>(null); + + // ===== 조회 (READ) ===== + + const refetch = useCallback( + async (options?: OverrideOptions): Promise => { + const currentConfig = configRef.current; + + // 테이블명 없으면 조회하지 않음 + if (!currentConfig.tableName?.trim()) { + return; + } + + setLoading(true); + setError(null); + + try { + const hasAggregation = !!currentConfig.aggregation; + const hasJoins = !!(currentConfig.joins && currentConfig.joins.length > 0); + + let result: DataSourceResult; + + if (hasAggregation || hasJoins) { + // 집계/조인 → SQL 빌더 경로 + // 설정 완료 여부 검증 + const merged = mergeFilters(currentConfig, options?.filters); + const validationError = validateDataSourceConfig(merged); + if (validationError) { + setError(validationError); + setLoading(false); + return; + } + result = await fetchWithSqlBuilder(merged); + } else { + // 단순 조회 → dataApi 경로 + result = await fetchSimpleTable(currentConfig, options?.filters); + } + + setData(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "데이터 조회 실패"; + setError(message); + } finally { + setLoading(false); + } + }, + [] // configRef 사용으로 의존성 불필요 + ); + + // ===== 생성 (CREATE) ===== + + const save = useCallback( + async (record: Record): Promise => { + const tableName = configRef.current.tableName; + if (!tableName?.trim()) { + return { success: false, error: "테이블이 설정되지 않았습니다" }; + } + + try { + const result = await dataApi.createRecord(tableName, record); + return { + success: result.success ?? true, + data: result.data, + error: result.message, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "레코드 생성 실패"; + return { success: false, error: message }; + } + }, + [] + ); + + // ===== 수정 (UPDATE) ===== + + const update = useCallback( + async ( + id: string | number, + record: Record + ): Promise => { + const tableName = configRef.current.tableName; + if (!tableName?.trim()) { + return { success: false, error: "테이블이 설정되지 않았습니다" }; + } + + try { + const result = await dataApi.updateRecord(tableName, id, record); + return { + success: result.success ?? true, + data: result.data, + error: result.message, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "레코드 수정 실패"; + return { success: false, error: message }; + } + }, + [] + ); + + // ===== 삭제 (DELETE) ===== + + const remove = useCallback( + async ( + id: string | number | Record + ): Promise => { + const tableName = configRef.current.tableName; + if (!tableName?.trim()) { + return { success: false, error: "테이블이 설정되지 않았습니다" }; + } + + try { + const result = await dataApi.deleteRecord(tableName, id); + return { + success: result.success ?? true, + data: result.data, + error: result.message, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "레코드 삭제 실패"; + return { success: false, error: message }; + } + }, + [] + ); + + // ===== 자동 조회 + 새로고침 ===== + + // config.tableName 또는 refreshInterval이 변경되면 재조회 + const tableName = config.tableName; + const refreshInterval = config.refreshInterval; + + useEffect(() => { + // 테이블명 있으면 초기 조회 + if (tableName?.trim()) { + refetch(); + } + + // refreshInterval 설정 시 자동 새로고침 + if (refreshInterval && refreshInterval > 0) { + const sec = Math.max(5, refreshInterval); // 최소 5초 + refreshTimerRef.current = setInterval(() => { + refetch(); + }, sec * 1000); + } + + return () => { + if (refreshTimerRef.current) { + clearInterval(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }; + }, [tableName, refreshInterval, refetch]); + + return { + data, + loading, + error, + refetch, + save, + update, + remove, + } as const; +} diff --git a/frontend/hooks/pop/usePopAction.ts b/frontend/hooks/pop/usePopAction.ts new file mode 100644 index 00000000..267beb4e --- /dev/null +++ b/frontend/hooks/pop/usePopAction.ts @@ -0,0 +1,218 @@ +/** + * usePopAction - POP 액션 실행 React 훅 + * + * executePopAction (순수 함수)를 래핑하여 React UI 관심사를 처리: + * - 로딩 상태 (isLoading) + * - 확인 다이얼로그 (pendingConfirm) + * - 토스트 알림 + * - 후속 액션 체이닝 (followUpActions) + * + * 사용처: + * - PopButtonComponent (메인 버튼) + * + * pop-string-list 등 리스트 컴포넌트는 executePopAction을 직접 호출하여 + * 훅 인스턴스 폭발 문제를 회피함. + */ + +import { useState, useCallback, useRef } from "react"; +import type { + ButtonMainAction, + FollowUpAction, + ConfirmConfig, +} from "@/lib/registry/pop-components/pop-button"; +import { usePopEvent } from "./usePopEvent"; +import { executePopAction } from "./executePopAction"; +import type { ActionResult } from "./executePopAction"; +import { toast } from "sonner"; + +// ======================================== +// 타입 정의 +// ======================================== + +/** 확인 대기 중인 액션 상태 */ +export interface PendingConfirmState { + action: ButtonMainAction; + rowData?: Record; + fieldMapping?: Record; + confirm: ConfirmConfig; + followUpActions?: FollowUpAction[]; +} + +/** execute 호출 시 옵션 */ +interface ExecuteActionOptions { + /** 대상 행 데이터 */ + rowData?: Record; + /** 필드 매핑 */ + fieldMapping?: Record; + /** 확인 다이얼로그 설정 */ + confirm?: ConfirmConfig; + /** 후속 액션 */ + followUpActions?: FollowUpAction[]; +} + +// ======================================== +// 상수 +// ======================================== + +/** 액션 성공 시 토스트 메시지 */ +const ACTION_SUCCESS_MESSAGES: Record = { + save: "저장되었습니다.", + delete: "삭제되었습니다.", + api: "요청이 완료되었습니다.", + modal: "", + event: "", +}; + +// ======================================== +// 메인 훅 +// ======================================== + +/** + * POP 액션 실행 훅 + * + * @param screenId - 화면 ID (이벤트 버스 연결용) + * @returns execute, isLoading, pendingConfirm, confirmExecute, cancelConfirm + */ +export function usePopAction(screenId: string) { + const [isLoading, setIsLoading] = useState(false); + const [pendingConfirm, setPendingConfirm] = useState(null); + + const { publish } = usePopEvent(screenId); + + // publish 안정성 보장 (콜백 내에서 최신 참조 사용) + const publishRef = useRef(publish); + publishRef.current = publish; + + /** + * 실제 실행 (확인 다이얼로그 이후 or 확인 불필요 시) + */ + const runAction = useCallback( + async ( + action: ButtonMainAction, + rowData?: Record, + fieldMapping?: Record, + followUpActions?: FollowUpAction[] + ): Promise => { + setIsLoading(true); + + try { + const result = await executePopAction(action, rowData, { + fieldMapping, + screenId, + publish: publishRef.current, + }); + + // 결과에 따른 토스트 + if (result.success) { + const msg = ACTION_SUCCESS_MESSAGES[action.type]; + if (msg) toast.success(msg); + } else { + toast.error(result.error || "작업에 실패했습니다."); + } + + // 성공 시 후속 액션 실행 + if (result.success && followUpActions?.length) { + await executeFollowUpActions(followUpActions); + } + + return result; + } finally { + setIsLoading(false); + } + }, + [screenId] + ); + + /** + * 후속 액션 실행 + */ + const executeFollowUpActions = useCallback( + async (actions: FollowUpAction[]) => { + for (const followUp of actions) { + switch (followUp.type) { + case "event": + if (followUp.eventName) { + publishRef.current(followUp.eventName, followUp.eventPayload); + } + break; + + case "refresh": + // 새로고침 이벤트 발행 (구독하는 컴포넌트가 refetch) + publishRef.current("__pop_refresh__"); + break; + + case "navigate": + if (followUp.targetScreenId) { + publishRef.current("__pop_navigate__", { + screenId: followUp.targetScreenId, + params: followUp.params, + }); + } + break; + + case "close-modal": + publishRef.current("__pop_modal_close__"); + break; + } + } + }, + [] + ); + + /** + * 외부에서 호출하는 실행 함수 + * confirm이 활성화되어 있으면 pendingConfirm에 저장하고 대기. + * 비활성화이면 즉시 실행. + */ + const execute = useCallback( + async ( + action: ButtonMainAction, + options?: ExecuteActionOptions + ): Promise => { + const { rowData, fieldMapping, confirm, followUpActions } = options || {}; + + // 확인 다이얼로그 필요 시 대기 + if (confirm?.enabled) { + setPendingConfirm({ + action, + rowData, + fieldMapping, + confirm, + followUpActions, + }); + return { success: true }; // 대기 상태이므로 일단 success + } + + // 즉시 실행 + return runAction(action, rowData, fieldMapping, followUpActions); + }, + [runAction] + ); + + /** + * 확인 다이얼로그에서 "확인" 클릭 시 + */ + const confirmExecute = useCallback(async () => { + if (!pendingConfirm) return; + + const { action, rowData, fieldMapping, followUpActions } = pendingConfirm; + setPendingConfirm(null); + + await runAction(action, rowData, fieldMapping, followUpActions); + }, [pendingConfirm, runAction]); + + /** + * 확인 다이얼로그에서 "취소" 클릭 시 + */ + const cancelConfirm = useCallback(() => { + setPendingConfirm(null); + }, []); + + return { + execute, + isLoading, + pendingConfirm, + confirmExecute, + cancelConfirm, + } as const; +} diff --git a/frontend/hooks/pop/usePopEvent.ts b/frontend/hooks/pop/usePopEvent.ts new file mode 100644 index 00000000..c600d838 --- /dev/null +++ b/frontend/hooks/pop/usePopEvent.ts @@ -0,0 +1,190 @@ +/** + * usePopEvent - POP 컴포넌트 간 이벤트 통신 훅 + * + * 같은 화면(screenId) 안에서만 동작하는 이벤트 버스. + * 다른 screenId 간에는 완전히 격리됨. + * + * 주요 기능: + * - publish/subscribe: 일회성 이벤트 (거래처 선택됨, 저장 완료 등) + * - getSharedData/setSharedData: 지속성 상태 (버튼 클릭 시 다른 컴포넌트 값 수집용) + * + * 사용 패턴: + * ```typescript + * const { publish, subscribe, getSharedData, setSharedData } = usePopEvent("S001"); + * + * // 이벤트 구독 (반드시 useEffect 안에서, cleanup 필수) + * useEffect(() => { + * const unsub = subscribe("supplier-selected", (payload) => { + * console.log(payload.supplierId); + * }); + * return unsub; + * }, []); + * + * // 이벤트 발행 + * publish("supplier-selected", { supplierId: "SUP-001" }); + * + * // 공유 데이터 저장/조회 + * setSharedData("selectedSupplier", { id: "SUP-001" }); + * const supplier = getSharedData("selectedSupplier"); + * ``` + */ + +import { useCallback, useRef } from "react"; + +// ===== 타입 정의 ===== + +/** 이벤트 콜백 함수 타입 */ +type EventCallback = (payload: unknown) => void; + +/** 화면별 이벤트 리스너 맵: eventName -> Set */ +type ListenerMap = Map>; + +/** 화면별 공유 데이터 맵: key -> value */ +type SharedDataMap = Map; + +// ===== 전역 저장소 (React 외부, 모듈 스코프) ===== +// SSR 환경에서 서버/클라이언트 간 공유 방지 + +/** screenId별 이벤트 리스너 저장소 */ +const screenBuses: Map = + typeof window !== "undefined" ? new Map() : new Map(); + +/** screenId별 공유 데이터 저장소 */ +const sharedDataStore: Map = + typeof window !== "undefined" ? new Map() : new Map(); + +// ===== 내부 헬퍼 ===== + +/** 해당 screenId의 리스너 맵 가져오기 (없으면 생성) */ +function getListenerMap(screenId: string): ListenerMap { + let map = screenBuses.get(screenId); + if (!map) { + map = new Map(); + screenBuses.set(screenId, map); + } + return map; +} + +/** 해당 screenId의 공유 데이터 맵 가져오기 (없으면 생성) */ +function getSharedMap(screenId: string): SharedDataMap { + let map = sharedDataStore.get(screenId); + if (!map) { + map = new Map(); + sharedDataStore.set(screenId, map); + } + return map; +} + +// ===== 외부 API: 화면 정리 ===== + +/** + * 화면 언마운트 시 해당 screenId의 모든 리스너 + 공유 데이터 정리 + * 메모리 누수 방지용. 뷰어 또는 PopRenderer에서 화면 전환 시 호출. + */ +export function cleanupScreen(screenId: string): void { + screenBuses.delete(screenId); + sharedDataStore.delete(screenId); +} + +// ===== 메인 훅 ===== + +/** + * POP 컴포넌트 간 이벤트 통신 훅 + * + * @param screenId - 화면 ID (같은 screenId 안에서만 통신) + * @returns publish, subscribe, getSharedData, setSharedData + */ +export function usePopEvent(screenId: string) { + // screenId를 ref로 저장 (콜백 안정성) + const screenIdRef = useRef(screenId); + screenIdRef.current = screenId; + + /** + * 이벤트 발행 + * 해당 screenId + eventName에 등록된 모든 콜백에 payload 전달 + */ + const publish = useCallback( + (eventName: string, payload?: unknown): void => { + const listeners = getListenerMap(screenIdRef.current); + const callbacks = listeners.get(eventName); + if (!callbacks || callbacks.size === 0) return; + + // Set을 배열로 복사 후 순회 (순회 중 unsubscribe 안전) + const callbackArray = Array.from(callbacks); + for (const cb of callbackArray) { + try { + cb(payload); + } catch (err) { + // 개별 콜백 에러가 다른 콜백 실행을 막지 않음 + console.error( + `[usePopEvent] 콜백 에러 (screen: ${screenIdRef.current}, event: ${eventName}):`, + err + ); + } + } + }, + [] + ); + + /** + * 이벤트 구독 + * + * 주의: 반드시 useEffect 안에서 호출하고, 반환값(unsubscribe)을 cleanup에서 호출할 것. + * + * @returns unsubscribe 함수 + */ + const subscribe = useCallback( + (eventName: string, callback: EventCallback): (() => void) => { + const listeners = getListenerMap(screenIdRef.current); + + let callbacks = listeners.get(eventName); + if (!callbacks) { + callbacks = new Set(); + listeners.set(eventName, callbacks); + } + callbacks.add(callback); + + // unsubscribe 함수 반환 + const capturedScreenId = screenIdRef.current; + return () => { + const map = screenBuses.get(capturedScreenId); + if (!map) return; + const cbs = map.get(eventName); + if (!cbs) return; + cbs.delete(callback); + // 빈 Set 정리 + if (cbs.size === 0) { + map.delete(eventName); + } + }; + }, + [] + ); + + /** + * 공유 데이터 조회 + * 다른 컴포넌트가 setSharedData로 저장한 값을 가져옴 + */ + const getSharedData = useCallback( + (key: string): T | undefined => { + const shared = sharedDataStore.get(screenIdRef.current); + if (!shared) return undefined; + return shared.get(key) as T | undefined; + }, + [] + ); + + /** + * 공유 데이터 저장 + * 같은 screenId의 다른 컴포넌트가 getSharedData로 읽을 수 있음 + */ + const setSharedData = useCallback( + (key: string, value: unknown): void => { + const shared = getSharedMap(screenIdRef.current); + shared.set(key, value); + }, + [] + ); + + return { publish, subscribe, getSharedData, setSharedData } as const; +} diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index e79d9f83..72af2a34 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -545,7 +545,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/lib/registry/PopComponentRegistry.ts b/frontend/lib/registry/PopComponentRegistry.ts index 31f9a4e1..0d7df5ec 100644 --- a/frontend/lib/registry/PopComponentRegistry.ts +++ b/frontend/lib/registry/PopComponentRegistry.ts @@ -2,6 +2,24 @@ import React from "react"; +/** + * 연결 메타 항목: 컴포넌트가 보내거나 받을 수 있는 데이터 슬롯 + */ +export interface ConnectionMetaItem { + key: string; + label: string; + type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string; + description?: string; +} + +/** + * 컴포넌트 연결 메타데이터: 디자이너가 연결 가능한 입출력 정의 + */ +export interface ComponentConnectionMeta { + sendable: ConnectionMetaItem[]; + receivable: ConnectionMetaItem[]; +} + /** * POP 컴포넌트 정의 인터페이스 */ @@ -15,6 +33,7 @@ export interface PopComponentDefinition { configPanel?: React.ComponentType; preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용 defaultProps?: Record; + connectionMeta?: ComponentConnectionMeta; // POP 전용 속성 touchOptimized?: boolean; minTouchArea?: number; diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 173a67ad..f753a240 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC = ({ )}
- {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} + {/* 확인 다이얼로그 */} - + {getConfirmTitle()} {getConfirmMessage()} diff --git a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx index 13a7ac4f..2f35c799 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx @@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC = ({ style, ...props }) => { - // 컴포넌트 설정 const componentConfig = { ...config, ...component.config, } as ImageDisplayConfig; - // 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) + const objectFit = componentConfig.objectFit || "contain"; + const altText = componentConfig.altText || "이미지"; + const borderRadius = componentConfig.borderRadius ?? 8; + const showBorder = componentConfig.showBorder ?? true; + const backgroundColor = componentConfig.backgroundColor || "#f9fafb"; + const placeholder = componentConfig.placeholder || "이미지 없음"; + + const imageSrc = component.value || componentConfig.imageUrl || ""; + const componentStyle: React.CSSProperties = { width: "100%", height: "100%", ...component.style, ...style, - // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) width: "100%", }; - // 디자인 모드 스타일 if (isDesignMode) { componentStyle.border = "1px dashed #cbd5e1"; componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; } - // 이벤트 핸들러 const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(); @@ -88,7 +92,9 @@ export const ImageDisplayComponent: React.FC = ({ }} > {component.label} - {component.required && *} + {(component.required || componentConfig.required) && ( + * + )} )} @@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC = ({ style={{ width: "100%", height: "100%", - border: "1px solid #d1d5db", - borderRadius: "8px", + border: showBorder ? "1px solid #d1d5db" : "none", + borderRadius: `${borderRadius}px`, overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center", - backgroundColor: "#f9fafb", + backgroundColor, transition: "all 0.2s ease-in-out", - boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)", + boxShadow: showBorder ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : "none", + opacity: componentConfig.disabled ? 0.5 : 1, + cursor: componentConfig.disabled ? "not-allowed" : "default", }} onMouseEnter={(e) => { - e.currentTarget.style.borderColor = "#f97316"; - e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)"; + if (!componentConfig.disabled) { + if (showBorder) { + e.currentTarget.style.borderColor = "#f97316"; + } + e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)"; + } }} onMouseLeave={(e) => { - e.currentTarget.style.borderColor = "#d1d5db"; - e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)"; + if (showBorder) { + e.currentTarget.style.borderColor = "#d1d5db"; + } + e.currentTarget.style.boxShadow = showBorder + ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" + : "none"; }} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd} > - {component.value || componentConfig.imageUrl ? ( + {imageSrc ? ( {componentConfig.altText { (e.target as HTMLImageElement).style.display = "none"; if (e.target?.parentElement) { e.target.parentElement.innerHTML = `
-
🖼️
+
이미지 로드 실패
`; @@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC = ({ fontSize: "14px", }} > -
🖼️
-
이미지 없음
+ + + + + +
{placeholder}
)}
@@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC = ({ /** * ImageDisplay 래퍼 컴포넌트 - * 추가적인 로직이나 상태 관리가 필요한 경우 사용 */ export const ImageDisplayWrapper: React.FC = (props) => { return ; diff --git a/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx b/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx index 6c73e1d9..7f36f51b 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx @@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types"; export interface ImageDisplayConfigPanelProps { config: ImageDisplayConfig; - onChange: (config: Partial) => void; + onChange?: (config: Partial) => void; + onConfigChange?: (config: Partial) => void; } /** * ImageDisplay 설정 패널 - * 컴포넌트의 설정값들을 편집할 수 있는 UI 제공 */ export const ImageDisplayConfigPanel: React.FC = ({ config, onChange, + onConfigChange, }) => { const handleChange = (key: keyof ImageDisplayConfig, value: any) => { - onChange({ [key]: value }); + const update = { ...config, [key]: value }; + onChange?.(update); + onConfigChange?.(update); }; return (
-
- image-display 설정 +
이미지 표시 설정
+ + {/* 이미지 URL */} +
+ + handleChange("imageUrl", e.target.value)} + placeholder="https://..." + className="h-8 text-xs" + /> +

+ 데이터 바인딩 값이 없을 때 표시할 기본 이미지 +

- {/* file 관련 설정 */} + {/* 대체 텍스트 */}
- + + handleChange("altText", e.target.value)} + placeholder="이미지 설명" + className="h-8 text-xs" + /> +
+ + {/* 이미지 맞춤 */} +
+ + +
+ + {/* 테두리 둥글기 */} +
+ + handleChange("borderRadius", parseInt(e.target.value) || 0)} + className="h-8 text-xs" + /> +
+ + {/* 배경 색상 */} +
+ +
+ handleChange("backgroundColor", e.target.value)} + className="h-8 w-8 cursor-pointer rounded border" + /> + handleChange("backgroundColor", e.target.value)} + className="h-8 flex-1 text-xs" + /> +
+
+ + {/* 플레이스홀더 */} +
+ handleChange("placeholder", e.target.value)} + placeholder="이미지 없음" + className="h-8 text-xs" />
- {/* 공통 설정 */} -
- + {/* 테두리 표시 */} +
handleChange("disabled", checked)} + id="showBorder" + checked={config.showBorder ?? true} + onCheckedChange={(checked) => handleChange("showBorder", checked)} /> +
-
- - handleChange("required", checked)} - /> -
- -
- + {/* 읽기 전용 */} +
handleChange("readonly", checked)} /> + +
+ + {/* 필수 입력 */} +
+ handleChange("required", checked)} + /> +
); diff --git a/frontend/lib/registry/components/image-display/config.ts b/frontend/lib/registry/components/image-display/config.ts index 268382f0..bae67e14 100644 --- a/frontend/lib/registry/components/image-display/config.ts +++ b/frontend/lib/registry/components/image-display/config.ts @@ -6,9 +6,14 @@ import { ImageDisplayConfig } from "./types"; * ImageDisplay 컴포넌트 기본 설정 */ export const ImageDisplayDefaultConfig: ImageDisplayConfig = { - placeholder: "입력하세요", - - // 공통 기본값 + imageUrl: "", + altText: "이미지", + objectFit: "contain", + borderRadius: 8, + showBorder: true, + backgroundColor: "#f9fafb", + placeholder: "이미지 없음", + disabled: false, required: false, readonly: false, @@ -18,23 +23,31 @@ export const ImageDisplayDefaultConfig: ImageDisplayConfig = { /** * ImageDisplay 컴포넌트 설정 스키마 - * 유효성 검사 및 타입 체크에 사용 */ export const ImageDisplayConfigSchema = { - placeholder: { type: "string", default: "" }, - - // 공통 스키마 + imageUrl: { type: "string", default: "" }, + altText: { type: "string", default: "이미지" }, + objectFit: { + type: "enum", + values: ["contain", "cover", "fill", "none", "scale-down"], + default: "contain", + }, + borderRadius: { type: "number", default: 8 }, + showBorder: { type: "boolean", default: true }, + backgroundColor: { type: "string", default: "#f9fafb" }, + placeholder: { type: "string", default: "이미지 없음" }, + disabled: { type: "boolean", default: false }, required: { type: "boolean", default: false }, readonly: { type: "boolean", default: false }, - variant: { - type: "enum", - values: ["default", "outlined", "filled"], - default: "default" + variant: { + type: "enum", + values: ["default", "outlined", "filled"], + default: "default", }, - size: { - type: "enum", - values: ["sm", "md", "lg"], - default: "md" + size: { + type: "enum", + values: ["sm", "md", "lg"], + default: "md", }, }; diff --git a/frontend/lib/registry/components/image-display/index.ts b/frontend/lib/registry/components/image-display/index.ts index ddb38f95..ffa5712a 100644 --- a/frontend/lib/registry/components/image-display/index.ts +++ b/frontend/lib/registry/components/image-display/index.ts @@ -21,7 +21,13 @@ export const ImageDisplayDefinition = createComponentDefinition({ webType: "file", component: ImageDisplayWrapper, defaultConfig: { - placeholder: "입력하세요", + imageUrl: "", + altText: "이미지", + objectFit: "contain", + borderRadius: 8, + showBorder: true, + backgroundColor: "#f9fafb", + placeholder: "이미지 없음", }, defaultSize: { width: 200, height: 200 }, configPanel: ImageDisplayConfigPanel, diff --git a/frontend/lib/registry/components/image-display/types.ts b/frontend/lib/registry/components/image-display/types.ts index f2b6971d..e882ebe4 100644 --- a/frontend/lib/registry/components/image-display/types.ts +++ b/frontend/lib/registry/components/image-display/types.ts @@ -6,20 +6,24 @@ import { ComponentConfig } from "@/types/component"; * ImageDisplay 컴포넌트 설정 타입 */ export interface ImageDisplayConfig extends ComponentConfig { - // file 관련 설정 + // 이미지 관련 설정 + imageUrl?: string; + altText?: string; + objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down"; + borderRadius?: number; + showBorder?: boolean; + backgroundColor?: string; placeholder?: string; - + // 공통 설정 disabled?: boolean; required?: boolean; readonly?: boolean; - placeholder?: string; - helperText?: string; - + // 스타일 관련 variant?: "default" | "outlined" | "filled"; size?: "sm" | "md" | "lg"; - + // 이벤트 관련 onChange?: (value: any) => void; onFocus?: () => void; @@ -37,7 +41,7 @@ export interface ImageDisplayProps { config?: ImageDisplayConfig; className?: string; style?: React.CSSProperties; - + // 이벤트 핸들러 onChange?: (value: any) => void; onFocus?: () => void; diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 3cf92664..a490f8b0 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -112,6 +112,8 @@ 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"; // 공정 작업기준 +import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅 import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선 import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰 import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기 diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 78969fd0..d57ae60b 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -162,6 +162,79 @@ export function RepeaterTable({ // 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행) const initializedRef = useRef(false); + // 편집 가능한 컬럼 인덱스 목록 (방향키 네비게이션용) + const editableColIndices = useMemo( + () => visibleColumns.reduce((acc, col, idx) => { + if (col.editable && !col.calculated) acc.push(idx); + return acc; + }, []), + [visibleColumns], + ); + + // 방향키로 리피터 셀 간 이동 + const handleArrowNavigation = useCallback( + (e: React.KeyboardEvent) => { + const key = e.key; + if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return; + + const target = e.target as HTMLElement; + const cell = target.closest("[data-repeater-row]") as HTMLElement | null; + if (!cell) return; + + const row = Number(cell.dataset.repeaterRow); + const col = Number(cell.dataset.repeaterCol); + if (isNaN(row) || isNaN(col)) return; + + // 텍스트 입력 중 좌/우 방향키는 커서 이동에 사용하므로 무시 + if ((key === "ArrowLeft" || key === "ArrowRight") && target.tagName === "INPUT") { + const input = target as HTMLInputElement; + const len = input.value?.length ?? 0; + const pos = input.selectionStart ?? 0; + // 커서가 끝에 있을 때만 오른쪽 이동, 처음에 있을 때만 왼쪽 이동 + if (key === "ArrowRight" && pos < len) return; + if (key === "ArrowLeft" && pos > 0) return; + } + + let nextRow = row; + let nextColPos = editableColIndices.indexOf(col); + + switch (key) { + case "ArrowUp": + nextRow = Math.max(0, row - 1); + break; + case "ArrowDown": + nextRow = Math.min(data.length - 1, row + 1); + break; + case "ArrowLeft": + nextColPos = Math.max(0, nextColPos - 1); + break; + case "ArrowRight": + nextColPos = Math.min(editableColIndices.length - 1, nextColPos + 1); + break; + } + + const nextCol = editableColIndices[nextColPos]; + if (nextRow === row && nextCol === col) return; + + e.preventDefault(); + + const selector = `[data-repeater-row="${nextRow}"][data-repeater-col="${nextCol}"]`; + const nextCell = containerRef.current?.querySelector(selector) as HTMLElement | null; + if (!nextCell) return; + + const focusable = nextCell.querySelector( + 'input:not([disabled]), select:not([disabled]), [role="combobox"]:not([disabled]), button:not([disabled])', + ); + if (focusable) { + focusable.focus(); + if (focusable.tagName === "INPUT") { + (focusable as HTMLInputElement).select(); + } + } + }, + [editableColIndices, data.length], + ); + // DnD 센서 설정 const sensors = useSensors( useSensor(PointerSensor, { @@ -480,15 +553,20 @@ export function RepeaterTable({ const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const value = row[column.field]; - // 🆕 카테고리 라벨 변환 함수 + // 카테고리 라벨 변환 함수 const getCategoryDisplayValue = (val: any): string => { if (!val || typeof val !== "string") return val || "-"; - // 카테고리 컬럼이 아니면 그대로 반환 - const fieldName = column.field.replace(/^_display_/, ""); // _display_ 접두사 제거 - if (!categoryColumns.includes(fieldName)) return val; + const fieldName = column.field.replace(/^_display_/, ""); + const isCategoryColumn = categoryColumns.includes(fieldName); - // 쉼표로 구분된 다중 값 처리 + // categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관) + if (categoryLabelMap[val]) return categoryLabelMap[val]; + + // 카테고리 컬럼이 아니면 원래 값 반환 + if (!isCategoryColumn) return val; + + // 콤마 구분된 다중 값 처리 const codes = val .split(",") .map((c: string) => c.trim()) @@ -643,7 +721,7 @@ export function RepeaterTable({ return ( -
+
{renderCell(row, col, rowIndex)} diff --git a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx index 63e1cbb9..57fe91d7 100644 --- a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx +++ b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx @@ -40,6 +40,7 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // 🆕 그룹화 설정 (예: groupByColumn: "inbound_number") const groupByColumn = rawConfig.groupByColumn; + const groupBySourceColumn = rawConfig.groupBySourceColumn || rawConfig.groupByColumn; const targetTable = rawConfig.targetTable; // 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑) @@ -86,8 +87,8 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // 🆕 formData와 config.fields의 필드 이름 매칭 확인 const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined); - // 🆕 그룹 키 값 (예: formData.inbound_number) - const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null; + // 🆕 그룹 키 값: groupBySourceColumn(formData 키)과 groupByColumn(DB 컬럼)을 분리 + const groupKeyValue = groupBySourceColumn ? formData?.[groupBySourceColumn] : null; // 🆕 분할 패널 위치 및 좌측 선택 데이터 확인 const splitPanelPosition = screenContext?.splitPanelPosition; diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 1f8b0484..c2be4bb4 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -13,8 +13,34 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { X } from "lucide-react"; -import * as LucideIcons from "lucide-react"; +import { + X, + Check, + Plus, + Minus, + Edit, + Trash2, + Search, + Save, + RefreshCw, + AlertCircle, + Info, + Settings, + ChevronDown, + ChevronUp, + ChevronRight, + Copy, + Download, + Upload, + ExternalLink, + type LucideIcon, +} from "lucide-react"; + +const LUCIDE_ICON_MAP: Record = { + X, Check, Plus, Minus, Edit, Trash2, Search, Save, RefreshCw, + AlertCircle, Info, Settings, ChevronDown, ChevronUp, ChevronRight, + Copy, Download, Upload, ExternalLink, +}; import { commonCodeApi } from "@/lib/api/commonCode"; import { cn } from "@/lib/utils"; @@ -1555,7 +1581,7 @@ export const SelectedItemsDetailInputComponent: React.FC; } diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 6264a757..bc4ba2ba 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -23,7 +23,8 @@ import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { useToast } from "@/hooks/use-toast"; import { tableTypeApi } from "@/lib/api/screen"; -import { apiClient } from "@/lib/api/client"; +import { apiClient, getFullImageUrl } from "@/lib/api/client"; +import { getFilePreviewUrl } from "@/lib/api/file"; import { Dialog, DialogContent, @@ -39,6 +40,80 @@ import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-opt import { useAuth } from "@/hooks/useAuth"; import { useSplitPanel } from "./SplitPanelContext"; +// 테이블 셀 이미지 썸네일 컴포넌트 +const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { + const [imgSrc, setImgSrc] = React.useState(null); + const [error, setError] = React.useState(false); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + let mounted = true; + const rawValue = String(value); + const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue; + const isObjid = /^\d+$/.test(strValue); + + if (isObjid) { + const loadImage = async () => { + try { + const response = await apiClient.get(`/files/preview/${strValue}`, { responseType: "blob" }); + if (mounted) { + const blob = new Blob([response.data]); + setImgSrc(window.URL.createObjectURL(blob)); + setLoading(false); + } + } catch { + if (mounted) { setError(true); setLoading(false); } + } + }; + loadImage(); + } else { + setImgSrc(getFullImageUrl(strValue)); + setLoading(false); + } + + return () => { mounted = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !imgSrc) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+ 이미지 { + e.stopPropagation(); + const rawValue = String(value); + const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue; + const isObjid = /^\d+$/.test(strValue); + window.open(isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue), "_blank"); + }} + onError={() => setError(true)} + /> +
+ ); +}); +SplitPanelCellImage.displayName = "SplitPanelCellImage"; + export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 props } @@ -182,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 const [leftColumnLabels, setLeftColumnLabels] = useState>({}); // 좌측 컬럼 라벨 const [rightColumnLabels, setRightColumnLabels] = useState>({}); // 우측 컬럼 라벨 + const [columnInputTypes, setColumnInputTypes] = useState>({}); // 테이블별 컬럼 inputType const [leftCategoryMappings, setLeftCategoryMappings] = useState< Record> >({}); // 좌측 카테고리 매핑 @@ -619,7 +695,7 @@ export const SplitPanelLayoutComponent: React.FC return result; }, []); - // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷) + // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷 + 이미지) const formatCellValue = useCallback( ( columnName: string, @@ -636,6 +712,12 @@ export const SplitPanelLayoutComponent: React.FC ) => { if (value === null || value === undefined) return "-"; + // 이미지 타입: 썸네일 표시 + const colInputType = columnInputTypes[columnName]; + if (colInputType === "image" && value) { + return ; + } + // 🆕 날짜 포맷 적용 if (format?.type === "date" || format?.dateFormat) { return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD"); @@ -702,7 +784,7 @@ export const SplitPanelLayoutComponent: React.FC // 일반 값 return String(value); }, - [formatDateValue, formatNumberValue], + [formatDateValue, formatNumberValue, columnInputTypes], ); // 좌측 데이터 로드 @@ -1453,14 +1535,36 @@ export const SplitPanelLayoutComponent: React.FC } }); setRightColumnLabels(labels); - console.log("✅ 우측 컬럼 라벨 로드:", labels); + + // 우측 테이블 + 추가 탭 테이블의 inputType 로드 + const tablesToLoad = new Set([rightTableName]); + const additionalTabs = componentConfig.rightPanel?.additionalTabs || []; + additionalTabs.forEach((tab: any) => { + if (tab.tableName) tablesToLoad.add(tab.tableName); + }); + + const inputTypes: Record = {}; + for (const tbl of tablesToLoad) { + try { + const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl); + inputTypesResponse.forEach((col: any) => { + const colName = col.columnName || col.column_name; + if (colName) { + inputTypes[colName] = col.inputType || "text"; + } + }); + } catch { + // inputType 로드 실패 시 무시 + } + } + setColumnInputTypes(inputTypes); } catch (error) { console.error("우측 테이블 컬럼 정보 로드 실패:", error); } }; loadRightTableColumns(); - }, [componentConfig.rightPanel?.tableName, isDesignMode]); + }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]); // 좌측 테이블 카테고리 매핑 로드 useEffect(() => { @@ -1603,6 +1707,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); // 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움 @@ -2483,14 +2638,14 @@ export const SplitPanelLayoutComponent: React.FC
{group.items.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; - const itemId = item[sourceColumn] || item.id || item.ID || idx; + const itemId = item[sourceColumn] || item.id || item.ID; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); return ( handleLeftItemSelect(item)} className={`hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" @@ -2545,14 +2700,14 @@ export const SplitPanelLayoutComponent: React.FC {filteredData.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; - const itemId = item[sourceColumn] || item.id || item.ID || idx; + const itemId = item[sourceColumn] || item.id || item.ID; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); return ( handleLeftItemSelect(item)} className={`hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" @@ -2647,7 +2802,8 @@ export const SplitPanelLayoutComponent: React.FC // 재귀 렌더링 함수 const renderTreeItem = (item: any, index: number): React.ReactNode => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; - const itemId = item[sourceColumn] || item.id || item.ID || index; + const rawItemId = item[sourceColumn] || item.id || item.ID; + const itemId = rawItemId != null ? rawItemId : index; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); const hasChildren = item.children && item.children.length > 0; @@ -2698,7 +2854,7 @@ export const SplitPanelLayoutComponent: React.FC const displaySubtitle = displayFields[1]?.value || null; return ( - + {/* 현재 항목 */}
return (
{currentTabData.map((item: any, idx: number) => { - const itemId = item.id || idx; + const itemId = item.id ?? idx; const isExpanded = expandedRightItems.has(itemId); // 표시할 컬럼 결정 @@ -3046,7 +3202,7 @@ export const SplitPanelLayoutComponent: React.FC const detailColumns = columnsToShow.slice(summaryCount); return ( -
+
toggleRightItemExpansion(itemId)} @@ -3236,10 +3392,10 @@ export const SplitPanelLayoutComponent: React.FC
{filteredData.map((item, idx) => { - const itemId = item.id || item.ID || idx; + const itemId = item.id || item.ID; return ( - + {columnsToShow.map((col, colIdx) => (
return (
{/* 요약 정보 */} diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index d0f9d5aa..aee70dd2 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -781,6 +781,7 @@ export const TableListComponent: React.FC = ({ const dataProvider: DataProvidable = { componentId: component.id, componentType: "table-list", + tableName: tableConfig.selectedTable, getSelectedData: () => { // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) @@ -940,23 +941,35 @@ export const TableListComponent: React.FC = ({ } } - // 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반 + // 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환) + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`); + + if (response.data.success && response.data.data && response.data.data.length > 0) { + return response.data.data.map((item: any) => ({ + value: String(item.value), + label: String(item.label), + })); + } + } catch { + // DISTINCT API 실패 시 현재 데이터 기반으로 fallback + } + + // fallback: 현재 로드된 데이터에서 고유 값 추출 const isLabelType = ["category", "entity", "code"].includes(inputType); const labelField = isLabelType ? `${columnName}_name` : columnName; - // 현재 로드된 데이터에서 고유 값 추출 - const uniqueValuesMap = new Map(); // value -> label + const uniqueValuesMap = new Map(); data.forEach((row) => { const value = row[columnName]; if (value !== null && value !== undefined && value !== "") { - // 백엔드 조인된 _name 필드 사용 (없으면 원본 값) const label = isLabelType && row[labelField] ? row[labelField] : String(value); uniqueValuesMap.set(String(value), label); } }); - // Map을 배열로 변환하고 라벨 기준으로 정렬 const result = Array.from(uniqueValuesMap.entries()) .map(([value, label]) => ({ value: value, @@ -4192,9 +4205,10 @@ export const TableListComponent: React.FC = ({ // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; - // 🖼️ 이미지 타입: 작은 썸네일 표시 + // 🖼️ 이미지 타입: 작은 썸네일 표시 (다중 이미지인 경우 대표 이미지 1개만) if (inputType === "image" && value && typeof value === "string") { - const imageUrl = getFullImageUrl(value); + const firstImage = value.includes(",") ? value.split(",")[0].trim() : value; + const imageUrl = getFullImageUrl(firstImage); return ( = ({ // 다중 값인 경우: 여러 배지 렌더링 return ( -
+
{values.map((val, idx) => { const categoryData = mapping?.[val]; const displayLabel = categoryData?.label || val; @@ -4316,7 +4330,7 @@ export const TableListComponent: React.FC = ({ // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 if (!displayColor || displayColor === "none" || !categoryData) { return ( - + {displayLabel} {idx < values.length - 1 && ", "} @@ -4330,7 +4344,7 @@ export const TableListComponent: React.FC = ({ backgroundColor: displayColor, borderColor: displayColor, }} - className="text-white" + className="shrink-0 whitespace-nowrap text-white" > {displayLabel} diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 120022a5..06226c9e 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -554,6 +554,69 @@ export function TableSectionRenderer({ loadCategoryOptions(); }, [tableConfig.source.tableName, tableConfig.columns]); + // receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드 + useEffect(() => { + if (!formData || Object.keys(formData).length === 0) return; + if (!tableConfig.columns) return; + + const codesToResolve: string[] = []; + for (const col of tableConfig.columns) { + // receiveFromParent 컬럼 + if ((col as any).receiveFromParent) { + const parentField = (col as any).parentFieldName || col.field; + const val = formData[parentField]; + if (typeof val === "string" && val) { + codesToResolve.push(val); + } + } + // internal 매핑 컬럼 + const mapping = (col as any).valueMapping; + if (mapping?.type === "internal" && mapping.internalField) { + const val = formData[mapping.internalField]; + if (typeof val === "string" && val) { + codesToResolve.push(val); + } + } + } + + if (codesToResolve.length === 0) return; + + const loadParentLabels = async () => { + try { + const resp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: codesToResolve, + }); + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + // categoryOptionsMap에 추가 (receiveFromParent 컬럼별로) + const newOptionsMap: Record = {}; + for (const col of tableConfig.columns) { + let val: string | undefined; + if ((col as any).receiveFromParent) { + const parentField = (col as any).parentFieldName || col.field; + val = formData[parentField] as string; + } + const mapping = (col as any).valueMapping; + if (mapping?.type === "internal" && mapping.internalField) { + val = formData[mapping.internalField] as string; + } + if (val && typeof val === "string" && labelData[val]) { + newOptionsMap[col.field] = [{ value: val, label: labelData[val] }]; + } + } + if (Object.keys(newOptionsMap).length > 0) { + setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap })); + } + } + } catch { + // 라벨 조회 실패 시 무시 + } + }; + + loadParentLabels(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formData, tableConfig.columns]); + // 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우) useEffect(() => { if (!isConditionalMode) return; @@ -1005,6 +1068,23 @@ export function TableSectionRenderer({ }); }, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]); + // categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생 + const tableCategoryColumns = useMemo(() => { + return Object.keys(categoryOptionsMap); + }, [categoryOptionsMap]); + + const tableCategoryLabelMap = useMemo(() => { + const map: Record = {}; + for (const options of Object.values(categoryOptionsMap)) { + for (const opt of options) { + if (opt.value && opt.label) { + map[opt.value] = opt.label; + } + } + } + return map; + }, [categoryOptionsMap]); + // 원본 계산 규칙 (조건부 계산 포함) const originalCalculationRules: TableCalculationRule[] = useMemo( () => tableConfig.calculations || [], @@ -1312,6 +1392,67 @@ export function TableSectionRenderer({ }), ); + // 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용) + const categoryFields = (tableConfig.columns || []) + .filter((col) => col.type === "category" || col.type === "select") + .reduce>>((acc, col) => { + const options = categoryOptionsMap[col.field]; + if (options && options.length > 0) { + acc[col.field] = {}; + for (const opt of options) { + acc[col.field][opt.value] = opt.label; + } + } + return acc; + }, {}); + + // receiveFromParent / internal 매핑으로 넘어온 값도 포함하여 변환 + if (Object.keys(categoryFields).length > 0) { + for (const item of mappedItems) { + for (const [field, codeToLabel] of Object.entries(categoryFields)) { + const val = item[field]; + if (typeof val === "string" && codeToLabel[val]) { + item[field] = codeToLabel[val]; + } + } + } + } + + // categoryOptionsMap에 없는 경우 API fallback + const unresolvedCodes = new Set(); + const categoryColFields = new Set( + (tableConfig.columns || []).filter((col) => col.type === "category").map((col) => col.field), + ); + for (const item of mappedItems) { + for (const field of categoryColFields) { + const val = item[field]; + if (typeof val === "string" && val && !categoryFields[field]?.[val] && val !== item[field]) { + unresolvedCodes.add(val); + } + } + } + + if (unresolvedCodes.size > 0) { + try { + const labelResp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(unresolvedCodes), + }); + if (labelResp.data?.success && labelResp.data.data) { + const labelData = labelResp.data.data as Record; + for (const item of mappedItems) { + for (const field of categoryColFields) { + const val = item[field]; + if (typeof val === "string" && labelData[val]) { + item[field] = labelData[val]; + } + } + } + } + } catch { + // 변환 실패 시 코드 유지 + } + } + // 계산 필드 업데이트 const calculatedItems = calculateAll(mappedItems); @@ -1319,7 +1460,7 @@ export function TableSectionRenderer({ const newData = [...tableData, ...calculatedItems]; handleDataChange(newData); }, - [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources], + [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources, categoryOptionsMap], ); // 컬럼 모드/조회 옵션 변경 핸들러 @@ -1667,6 +1808,31 @@ export function TableSectionRenderer({ }), ); + // 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용) + const categoryFields = (tableConfig.columns || []) + .filter((col) => col.type === "category" || col.type === "select") + .reduce>>((acc, col) => { + const options = categoryOptionsMap[col.field]; + if (options && options.length > 0) { + acc[col.field] = {}; + for (const opt of options) { + acc[col.field][opt.value] = opt.label; + } + } + return acc; + }, {}); + + if (Object.keys(categoryFields).length > 0) { + for (const item of mappedItems) { + for (const [field, codeToLabel] of Object.entries(categoryFields)) { + const val = item[field]; + if (typeof val === "string" && codeToLabel[val]) { + item[field] = codeToLabel[val]; + } + } + } + } + // 현재 조건의 데이터에 추가 const currentData = conditionalTableData[modalCondition] || []; const newData = [...currentData, ...mappedItems]; @@ -1964,6 +2130,8 @@ export function TableSectionRenderer({ [conditionValue]: newSelected, })); }} + categoryColumns={tableCategoryColumns} + categoryLabelMap={tableCategoryLabelMap} equalizeWidthsTrigger={widthTrigger} /> @@ -2055,6 +2223,8 @@ export function TableSectionRenderer({ })); }} equalizeWidthsTrigger={widthTrigger} + categoryColumns={tableCategoryColumns} + categoryLabelMap={tableCategoryLabelMap} /> ); @@ -2185,6 +2355,8 @@ export function TableSectionRenderer({ selectedRows={selectedRows} onSelectionChange={setSelectedRows} equalizeWidthsTrigger={widthTrigger} + categoryColumns={tableCategoryColumns} + categoryLabelMap={tableCategoryLabelMap} /> {/* 항목 선택 모달 */} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 26acaf34..6d55b650 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -28,6 +28,7 @@ import { apiClient } from "@/lib/api/client"; import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { CascadingDropdownConfig } from "@/types/screen-management"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { UniversalFormModalComponentProps, @@ -247,6 +248,10 @@ export function UniversalFormModalComponent({ // 폼 데이터 상태 const [formData, setFormData] = useState({}); + // formDataRef: 항상 최신 formData를 유지하는 ref + // React 상태 업데이트는 비동기적이므로, handleBeforeFormSave 등에서 + // 클로저의 formData가 오래된 값을 참조하는 문제를 방지 + const formDataRef = useRef({}); const [, setOriginalData] = useState>({}); // 반복 섹션 데이터 @@ -398,18 +403,19 @@ export function UniversalFormModalComponent({ console.log("[UniversalFormModal] beforeFormSave 이벤트 수신"); console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields)); + // formDataRef.current 사용: blur+click 동시 처리 시 클로저의 formData가 오래된 문제 방지 + const latestFormData = formDataRef.current; + // 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용) - // - 신규 등록: formData.id가 없으므로 영향 없음 - // - 편집 모드: formData.id가 있으면 메인 테이블 UPDATE에 사용 - if (formData.id !== undefined && formData.id !== null && formData.id !== "") { - event.detail.formData.id = formData.id; - console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, formData.id); + if (latestFormData.id !== undefined && latestFormData.id !== null && latestFormData.id !== "") { + event.detail.formData.id = latestFormData.id; + console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, latestFormData.id); } // UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함) // 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀 // (UniversalFormModal이 해당 필드의 주인이므로) - for (const [key, value] of Object.entries(formData)) { + for (const [key, value] of Object.entries(latestFormData)) { // 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합 const isConfiguredField = configuredFields.has(key); const isNumberingRuleId = key.endsWith("_numberingRuleId"); @@ -432,17 +438,13 @@ export function UniversalFormModalComponent({ } // 🆕 테이블 섹션 데이터 병합 (품목 리스트 등) - // 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블), - // handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용 - for (const [key, value] of Object.entries(formData)) { - // 싱글/더블 언더스코어 모두 처리 + // formDataRef.current(= latestFormData) 사용: React 상태 커밋 전에도 최신 데이터 보장 + for (const [key, value] of Object.entries(latestFormData)) { + // _tableSection_ 과 __tableSection_ 모두 원본 키 그대로 전달 + // buttonActions.ts에서 DB데이터(__tableSection_)와 수정데이터(_tableSection_)를 구분하여 병합 if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) { - // 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대) - const normalizedKey = key.startsWith("__tableSection_") - ? key.replace("__tableSection_", "_tableSection_") - : key; - event.detail.formData[normalizedKey] = value; - console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key} → ${normalizedKey}, ${value.length}개 항목`); + event.detail.formData[key] = value; + console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`); } // 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용) @@ -457,6 +459,22 @@ export function UniversalFormModalComponent({ event.detail.formData._originalGroupedData = originalGroupedData; console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`); } + + // 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트 + // onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트 + for (const parentKey of Object.keys(event.detail.formData)) { + const parentValue = event.detail.formData[parentKey]; + if (parentValue && typeof parentValue === "object" && !Array.isArray(parentValue)) { + const hasTableSection = Object.keys(parentValue).some( + (k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"), + ); + if (hasTableSection) { + event.detail.formData[parentKey] = { ...latestFormData }; + console.log(`[UniversalFormModal] 부모 중첩 객체 업데이트: ${parentKey}`); + break; + } + } + } }; window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); @@ -482,10 +500,11 @@ export function UniversalFormModalComponent({ // 테이블 섹션 데이터 설정 const tableSectionKey = `_tableSection_${tableSection.id}`; - setFormData((prev) => ({ - ...prev, - [tableSectionKey]: _groupedData, - })); + setFormData((prev) => { + const newData = { ...prev, [tableSectionKey]: _groupedData }; + formDataRef.current = newData; + return newData; + }); groupedDataInitializedRef.current = true; }, [_groupedData, config.sections]); @@ -965,6 +984,7 @@ export function UniversalFormModalComponent({ } setFormData(newFormData); + formDataRef.current = newFormData; setRepeatSections(newRepeatSections); setCollapsedSections(newCollapsed); setActivatedOptionalFieldGroups(newActivatedGroups); @@ -1132,6 +1152,9 @@ export function UniversalFormModalComponent({ console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`); } + // ref 즉시 업데이트 (React 상태 커밋 전에도 최신 데이터 접근 가능) + formDataRef.current = newData; + // onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용) if (onChange) { setTimeout(() => onChange(newData), 0); @@ -1813,11 +1836,11 @@ export function UniversalFormModalComponent({ case "date": return ( - onChangeHandler(e.target.value)} + onChange={onChangeHandler} + placeholder={field.placeholder || "날짜를 선택하세요"} disabled={isDisabled} readOnly={field.readOnly} /> @@ -1825,13 +1848,14 @@ export function UniversalFormModalComponent({ case "datetime": return ( - onChangeHandler(e.target.value)} + onChange={onChangeHandler} + placeholder={field.placeholder || "날짜/시간을 선택하세요"} disabled={isDisabled} readOnly={field.readOnly} + includeTime /> ); diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index a937f5b2..c6673d8d 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -393,7 +393,7 @@ export interface TableModalFilter { export interface TableColumnConfig { field: string; // 필드명 (저장할 컬럼명) label: string; // 컬럼 헤더 라벨 - type: "text" | "number" | "date" | "select"; // 입력 타입 + type: "text" | "number" | "date" | "select" | "category"; // 입력 타입 // 소스 필드 매핑 (검색 모달에서 가져올 컬럼명) sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일) diff --git a/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx new file mode 100644 index 00000000..98a9e823 --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx @@ -0,0 +1,609 @@ +"use client"; + +import React, { useState, useRef, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { + Upload, + FileSpreadsheet, + AlertCircle, + CheckCircle2, + Download, + Loader2, + X, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { importFromExcel } from "@/lib/utils/excelExport"; +import { apiClient } from "@/lib/api/client"; + +interface BomExcelUploadModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; + /** bomId가 있으면 "새 버전 등록" 모드, 없으면 "새 BOM 생성" 모드 */ + bomId?: string; + bomName?: string; +} + +interface ParsedRow { + rowIndex: number; + level: number; + item_number: string; + item_name: string; + quantity: number; + unit: string; + process_type: string; + remark: string; + valid: boolean; + error?: string; + isHeader?: boolean; +} + +type UploadStep = "upload" | "preview" | "result"; + +const EXPECTED_HEADERS = ["레벨", "품번", "품명", "소요량", "단위", "공정구분", "비고"]; + +const HEADER_MAP: Record = { + "레벨": "level", + "level": "level", + "품번": "item_number", + "품목코드": "item_number", + "item_number": "item_number", + "item_code": "item_number", + "품명": "item_name", + "품목명": "item_name", + "item_name": "item_name", + "소요량": "quantity", + "수량": "quantity", + "quantity": "quantity", + "qty": "quantity", + "단위": "unit", + "unit": "unit", + "공정구분": "process_type", + "공정": "process_type", + "process_type": "process_type", + "비고": "remark", + "remark": "remark", +}; + +export function BomExcelUploadModal({ + open, + onOpenChange, + onSuccess, + bomId, + bomName, +}: BomExcelUploadModalProps) { + const isVersionMode = !!bomId; + + const [step, setStep] = useState("upload"); + const [parsedRows, setParsedRows] = useState([]); + const [fileName, setFileName] = useState(""); + const [uploading, setUploading] = useState(false); + const [uploadResult, setUploadResult] = useState(null); + const [downloading, setDownloading] = useState(false); + const [versionName, setVersionName] = useState(""); + const fileInputRef = useRef(null); + + const reset = useCallback(() => { + setStep("upload"); + setParsedRows([]); + setFileName(""); + setUploadResult(null); + setUploading(false); + setVersionName(""); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, []); + + const handleClose = useCallback(() => { + reset(); + onOpenChange(false); + }, [reset, onOpenChange]); + + const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setFileName(file.name); + + try { + const rawData = await importFromExcel(file); + if (!rawData || rawData.length === 0) { + toast.error("엑셀 파일에 데이터가 없습니다"); + return; + } + + const firstRow = rawData[0]; + const excelHeaders = Object.keys(firstRow); + const fieldMap: Record = {}; + + for (const header of excelHeaders) { + const normalized = header.trim().toLowerCase(); + const mapped = HEADER_MAP[normalized] || HEADER_MAP[header.trim()]; + if (mapped) { + fieldMap[header] = mapped; + } + } + + const hasItemNumber = excelHeaders.some(h => { + const n = h.trim().toLowerCase(); + return HEADER_MAP[n] === "item_number" || HEADER_MAP[h.trim()] === "item_number"; + }); + if (!hasItemNumber) { + toast.error("품번 컬럼을 찾을 수 없습니다. 컬럼명을 확인해주세요."); + return; + } + + const parsed: ParsedRow[] = []; + for (let index = 0; index < rawData.length; index++) { + const row = rawData[index]; + const getField = (fieldName: string): any => { + for (const [excelKey, mappedField] of Object.entries(fieldMap)) { + if (mappedField === fieldName) return row[excelKey]; + } + return undefined; + }; + + const levelRaw = getField("level"); + const level = typeof levelRaw === "number" ? levelRaw : parseInt(String(levelRaw || "0"), 10); + const itemNumber = String(getField("item_number") || "").trim(); + const itemName = String(getField("item_name") || "").trim(); + const quantityRaw = getField("quantity"); + const quantity = typeof quantityRaw === "number" ? quantityRaw : parseFloat(String(quantityRaw || "1")); + const unit = String(getField("unit") || "").trim(); + const processType = String(getField("process_type") || "").trim(); + const remark = String(getField("remark") || "").trim(); + + let valid = true; + let error = ""; + const isHeader = level === 0; + + if (!itemNumber) { + valid = false; + error = "품번 필수"; + } else if (isNaN(level) || level < 0) { + valid = false; + error = "레벨 오류"; + } else if (index > 0) { + const prevLevel = parsed[index - 1]?.level ?? 0; + if (level > prevLevel + 1) { + valid = false; + error = `레벨 점프 (이전: ${prevLevel})`; + } + } + + parsed.push({ + rowIndex: index + 1, + isHeader, + level, + item_number: itemNumber, + item_name: itemName, + quantity: isNaN(quantity) ? 1 : quantity, + unit, + process_type: processType, + remark, + valid, + error, + }); + } + + const filtered = parsed.filter(r => r.item_number !== ""); + + // 새 BOM 생성 모드: 레벨 0 필수 + if (!isVersionMode) { + const hasHeader = filtered.some(r => r.level === 0); + if (!hasHeader) { + toast.error("레벨 0(BOM 마스터) 행이 필요합니다. 첫 행에 최상위 품목을 입력해주세요."); + return; + } + } + + setParsedRows(filtered); + setStep("preview"); + } catch (err: any) { + toast.error(`파일 파싱 실패: ${err.message}`); + } + }, [isVersionMode]); + + const handleUpload = useCallback(async () => { + const invalidRows = parsedRows.filter(r => !r.valid); + if (invalidRows.length > 0) { + toast.error(`유효하지 않은 행이 ${invalidRows.length}건 있습니다.`); + return; + } + + setUploading(true); + try { + const rowPayload = parsedRows.map(r => ({ + level: r.level, + item_number: r.item_number, + item_name: r.item_name, + quantity: r.quantity, + unit: r.unit, + process_type: r.process_type, + remark: r.remark, + })); + + let res; + if (isVersionMode) { + res = await apiClient.post(`/bom/${bomId}/excel-upload-version`, { + rows: rowPayload, + versionName: versionName.trim() || undefined, + }); + } else { + res = await apiClient.post("/bom/excel-upload", { rows: rowPayload }); + } + + if (res.data?.success) { + setUploadResult(res.data.data); + setStep("result"); + const msg = isVersionMode + ? `새 버전 생성 완료: 하위품목 ${res.data.data.insertedCount}건` + : `BOM 생성 완료: 하위품목 ${res.data.data.insertedCount}건`; + toast.success(msg); + onSuccess?.(); + } else { + const errData = res.data?.data; + if (errData?.unmatchedItems?.length > 0) { + toast.error(`매칭 안 되는 품번: ${errData.unmatchedItems.join(", ")}`); + setParsedRows(prev => prev.map(r => { + if (errData.unmatchedItems.includes(r.item_number)) { + return { ...r, valid: false, error: "품번 미등록" }; + } + return r; + })); + } else { + toast.error(res.data?.message || "업로드 실패"); + } + } + } catch (err: any) { + toast.error(`업로드 오류: ${err.response?.data?.message || err.message}`); + } finally { + setUploading(false); + } + }, [parsedRows, isVersionMode, bomId, versionName, onSuccess]); + + const handleDownloadTemplate = useCallback(async () => { + setDownloading(true); + try { + const XLSX = await import("xlsx"); + let data: Record[] = []; + + if (isVersionMode && bomId) { + // 기존 BOM 데이터를 템플릿으로 다운로드 + try { + const res = await apiClient.get(`/bom/${bomId}/excel-download`); + if (res.data?.success && res.data.data?.length > 0) { + data = res.data.data.map((row: any) => ({ + "레벨": row.level, + "품번": row.item_number, + "품명": row.item_name, + "소요량": row.quantity, + "단위": row.unit, + "공정구분": row.process_type, + "비고": row.remark, + })); + } + } catch { /* 데이터 없으면 빈 템플릿 */ } + } + + if (data.length === 0) { + if (isVersionMode) { + data = [ + { "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" }, + { "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" }, + ]; + } else { + data = [ + { "레벨": 0, "품번": "(최상위 품번)", "품명": "(최상위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "BOM 마스터" }, + { "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" }, + { "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" }, + ]; + } + } + + const ws = XLSX.utils.json_to_sheet(data); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "BOM"); + ws["!cols"] = [ + { wch: 6 }, { wch: 18 }, { wch: 20 }, { wch: 10 }, + { wch: 8 }, { wch: 12 }, { wch: 20 }, + ]; + + const filename = bomName ? `BOM_${bomName}.xlsx` : "BOM_template.xlsx"; + XLSX.writeFile(wb, filename); + toast.success("템플릿 다운로드 완료"); + } catch (err: any) { + toast.error(`다운로드 실패: ${err.message}`); + } finally { + setDownloading(false); + } + }, [isVersionMode, bomId, bomName]); + + const headerRow = parsedRows.find(r => r.isHeader); + const detailRows = parsedRows.filter(r => !r.isHeader); + const validCount = parsedRows.filter(r => r.valid).length; + const invalidCount = parsedRows.filter(r => !r.valid).length; + + const title = isVersionMode ? "BOM 새 버전 엑셀 업로드" : "BOM 엑셀 업로드"; + const description = isVersionMode + ? `${bomName || "선택된 BOM"}의 새 버전을 엑셀 파일로 생성합니다. 레벨 0 행은 건너뜁니다.` + : "엑셀 파일로 새 BOM을 생성합니다. 레벨 0 = BOM 마스터, 레벨 1 이상 = 하위품목."; + + return ( + { if (!v) handleClose(); }}> + + + {title} + {description} + + + {/* Step 1: 파일 업로드 */} + {step === "upload" && ( +
+ {/* 새 버전 모드: 버전명 입력 */} + {isVersionMode && ( +
+ + setVersionName(e.target.value)} + placeholder="예: 2.0" + className="h-8 text-xs sm:h-10 sm:text-sm mt-1" + /> +
+ )} + +
fileInputRef.current?.click()} + > + +

엑셀 파일을 선택하세요

+

.xlsx, .xls, .csv 형식 지원

+ +
+ +
+

엑셀 컬럼 형식

+
+ {EXPECTED_HEADERS.map((h, i) => ( + + {h}{i < 2 ? " *" : ""} + + ))} +
+

+ {isVersionMode + ? "* 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목. 레벨 0 행이 있으면 건너뜁니다." + : "* 레벨 0 = BOM 마스터(최상위 품목, 1행), 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목." + } +

+
+ + +
+ )} + + {/* Step 2: 미리보기 */} + {step === "preview" && ( +
+
+
+ {fileName} + {!isVersionMode && headerRow && ( + 마스터: {headerRow.item_number} + )} + + 하위품목 {detailRows.length}건 + + {invalidCount > 0 && ( + + {invalidCount}건 오류 + + )} +
+ +
+ +
+ + + + + + + + + + + + + + + + {parsedRows.map((row) => ( + + + + + + + + + + + + ))} + +
#구분레벨품번품명소요량단위공정비고
{row.rowIndex} + {row.isHeader ? ( + + {isVersionMode ? "건너뜀" : "마스터"} + + ) : row.valid ? ( + + ) : ( + + + + )} + + + {row.level} + + {row.item_number}{row.item_name}{row.quantity}{row.unit}{row.process_type}{row.remark}
+
+ + {invalidCount > 0 && ( +
+
유효하지 않은 행 ({invalidCount}건)
+
    + {parsedRows.filter(r => !r.valid).slice(0, 5).map(r => ( +
  • {r.rowIndex}행: {r.error}
  • + ))} + {invalidCount > 5 &&
  • ...외 {invalidCount - 5}건
  • } +
+
+ )} + +
+ {isVersionMode + ? "레벨 1 이상의 하위품목으로 새 버전을 생성합니다." + : "레벨 0 품목으로 새 BOM 마스터를 생성하고, 레벨 1 이상은 하위품목으로 등록합니다." + } +
+
+ )} + + {/* Step 3: 결과 */} + {step === "result" && uploadResult && ( +
+
+
+ +
+

+ {isVersionMode ? "새 버전 생성 완료" : "BOM 생성 완료"} +

+

+ 하위품목 {uploadResult.insertedCount}건이 등록되었습니다. +

+
+ +
+ {!isVersionMode && ( +
+
1
+
BOM 마스터
+
+ )} +
+
{uploadResult.insertedCount}
+
하위품목
+
+
+
+ )} + + + {step === "upload" && ( + + )} + {step === "preview" && ( + <> + + + + )} + {step === "result" && ( + + )} + +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index fd1e24aa..e98dbf88 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -14,6 +14,7 @@ import { History, GitBranch, Check, + FileSpreadsheet, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -22,6 +23,7 @@ import { Button } from "@/components/ui/button"; import { BomDetailEditModal } from "./BomDetailEditModal"; import { BomHistoryModal } from "./BomHistoryModal"; import { BomVersionModal } from "./BomVersionModal"; +import { BomExcelUploadModal } from "./BomExcelUploadModal"; interface BomTreeNode { id: string; @@ -77,6 +79,7 @@ export function BomTreeComponent({ const [editTargetNode, setEditTargetNode] = useState(null); const [historyModalOpen, setHistoryModalOpen] = useState(false); const [versionModalOpen, setVersionModalOpen] = useState(false); + const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [colWidths, setColWidths] = useState>({}); const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => { @@ -837,6 +840,15 @@ export function BomTreeComponent({ 버전 )} +
); } diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 5516a4bf..c00c1b1f 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -724,17 +724,28 @@ export const ButtonPrimaryComponent: React.FC = ({ try { // 1. 소스 컴포넌트에서 데이터 가져오기 - let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId); + let sourceProvider: import("@/types/data-transfer").DataProvidable | undefined; - // 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색 - // (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응) + const isAutoSource = + !dataTransferConfig.sourceComponentId || dataTransferConfig.sourceComponentId === "__auto__"; + + if (!isAutoSource) { + sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId); + } + + // 자동 탐색 모드이거나, 지정된 소스를 찾지 못한 경우 + // 현재 마운트된 DataProvider 중에서 table-list를 자동 탐색 if (!sourceProvider) { - console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`); - console.log("🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색..."); + if (!isAutoSource) { + console.log( + `⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`, + ); + } + console.log("🔍 [ButtonPrimary] 현재 활성 DataProvider 자동 탐색..."); const allProviders = screenContext.getAllDataProviders(); - // 테이블 리스트 우선 탐색 + // table-list 우선 탐색 for (const [id, provider] of allProviders) { if (provider.componentType === "table-list") { sourceProvider = provider; @@ -743,7 +754,7 @@ export const ButtonPrimaryComponent: React.FC = ({ } } - // 테이블 리스트가 없으면 첫 번째 DataProvider 사용 + // table-list가 없으면 첫 번째 DataProvider 사용 if (!sourceProvider && allProviders.size > 0) { const firstEntry = allProviders.entries().next().value; if (firstEntry) { @@ -784,15 +795,12 @@ export const ButtonPrimaryComponent: React.FC = ({ const additionalValues = additionalProvider.getSelectedData(); if (additionalValues && additionalValues.length > 0) { - // 첫 번째 값 사용 (조건부 컨테이너는 항상 1개) const firstValue = additionalValues[0]; - // fieldName이 지정되어 있으면 그 필드만 추출 if (additionalSource.fieldName) { additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue; } else { - // fieldName이 없으면 전체 객체 병합 additionalData = { ...additionalData, ...firstValue }; } @@ -802,6 +810,25 @@ export const ButtonPrimaryComponent: React.FC = ({ value: additionalData[additionalSource.fieldName || "all"], }); } + } else if (formData) { + // DataProvider로 등록되지 않은 컴포넌트(v2-select 등)는 formData에서 값을 가져옴 + const comp = allComponents?.find((c: any) => c.id === additionalSource.componentId); + const columnName = + comp?.columnName || + comp?.componentConfig?.columnName || + comp?.overrides?.columnName; + + if (columnName && formData[columnName] !== undefined && formData[columnName] !== "") { + const targetField = additionalSource.fieldName || columnName; + additionalData[targetField] = formData[columnName]; + + console.log("📦 추가 데이터 수집 (formData 폴백):", { + sourceId: additionalSource.componentId, + columnName, + targetField, + value: formData[columnName], + }); + } } } } @@ -870,44 +897,126 @@ export const ButtonPrimaryComponent: React.FC = ({ } } - // 4. 매핑 규칙 적용 + 추가 데이터 병합 - const mappedData = sourceData.map((row) => { - const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []); + // 4. 매핑 규칙 결정: 멀티 테이블 매핑 또는 레거시 단일 매핑 + let effectiveMappingRules: any[] = dataTransferConfig.mappingRules || []; + + const sourceTableName = sourceProvider?.tableName; + const multiTableMappings: Array<{ sourceTable: string; mappingRules: any[] }> = + dataTransferConfig.multiTableMappings || []; + + if (multiTableMappings.length > 0 && sourceTableName) { + const matchedGroup = multiTableMappings.find((g) => g.sourceTable === sourceTableName); + if (matchedGroup) { + effectiveMappingRules = matchedGroup.mappingRules || []; + console.log(`✅ [ButtonPrimary] 멀티 테이블 매핑 적용: ${sourceTableName}`, effectiveMappingRules); + } else { + console.log(`⚠️ [ButtonPrimary] 소스 테이블 ${sourceTableName}에 대한 매핑 없음, 동일 필드명 자동 매핑`); + effectiveMappingRules = []; + } + } else if (multiTableMappings.length > 0 && !sourceTableName) { + console.log("⚠️ [ButtonPrimary] 소스 테이블 미감지, 첫 번째 매핑 그룹 사용"); + effectiveMappingRules = multiTableMappings[0]?.mappingRules || []; + } + + const mappedData = sourceData.map((row) => { + const mappedRow = applyMappingRules(row, effectiveMappingRules); - // 추가 데이터를 모든 행에 포함 return { ...mappedRow, ...additionalData, }; }); + // 5. targetType / targetComponentId 기본값 및 자동 탐색 + const effectiveTargetType = dataTransferConfig.targetType || "component"; + let effectiveTargetComponentId = dataTransferConfig.targetComponentId; + + // targetComponentId가 없으면 현재 화면에서 DataReceiver 자동 탐색 + if (effectiveTargetType === "component" && !effectiveTargetComponentId) { + console.log("🔍 [ButtonPrimary] 타겟 컴포넌트 자동 탐색..."); + const allReceivers = screenContext.getAllDataReceivers(); + + // repeater 계열 우선 탐색 + for (const [id, receiver] of allReceivers) { + if ( + receiver.componentType === "repeater-field-group" || + receiver.componentType === "v2-repeater" || + receiver.componentType === "repeater" + ) { + effectiveTargetComponentId = id; + console.log(`✅ [ButtonPrimary] 리피터 자동 발견: ${id} (${receiver.componentType})`); + break; + } + } + + // repeater가 없으면 소스가 아닌 첫 번째 DataReceiver 사용 + if (!effectiveTargetComponentId) { + for (const [id, receiver] of allReceivers) { + if (receiver.componentType === "table-list" || receiver.componentType === "data-table") { + effectiveTargetComponentId = id; + console.log(`✅ [ButtonPrimary] DataReceiver 자동 발견: ${id} (${receiver.componentType})`); + break; + } + } + } + + if (!effectiveTargetComponentId) { + toast.error("데이터를 받을 수 있는 타겟 컴포넌트를 찾을 수 없습니다."); + return; + } + } + console.log("📦 데이터 전달:", { sourceData, mappedData, - targetType: dataTransferConfig.targetType, - targetComponentId: dataTransferConfig.targetComponentId, + targetType: effectiveTargetType, + targetComponentId: effectiveTargetComponentId, targetScreenId: dataTransferConfig.targetScreenId, }); - // 5. 타겟으로 데이터 전달 - if (dataTransferConfig.targetType === "component") { - // 같은 화면의 컴포넌트로 전달 - const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId); + // 6. 타겟으로 데이터 전달 + if (effectiveTargetType === "component") { + const targetReceiver = screenContext.getDataReceiver(effectiveTargetComponentId); + + const receiverConfig = { + targetComponentId: effectiveTargetComponentId, + targetComponentType: targetReceiver?.componentType || ("table" as const), + mode: dataTransferConfig.mode || ("append" as const), + mappingRules: dataTransferConfig.mappingRules || [], + }; if (!targetReceiver) { - toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`); + // 타겟이 아직 마운트되지 않은 경우 (조건부 레이어 등) + // 버퍼에 저장하고 레이어 활성화 요청 + console.log( + `⏳ [ButtonPrimary] 타겟 컴포넌트 미마운트, 대기열에 추가: ${effectiveTargetComponentId}`, + ); + + screenContext.addPendingTransfer({ + targetComponentId: effectiveTargetComponentId, + data: mappedData, + config: receiverConfig, + timestamp: Date.now(), + targetLayerId: dataTransferConfig.targetLayerId, + }); + + // 레이어 활성화 이벤트 발행 (page.tsx에서 수신) + const activateEvent = new CustomEvent("activateLayerForComponent", { + detail: { + componentId: effectiveTargetComponentId, + targetLayerId: dataTransferConfig.targetLayerId, + }, + }); + window.dispatchEvent(activateEvent); + + toast.info(`타겟 레이어를 활성화하고 데이터 전달을 준비합니다...`); return; } - await targetReceiver.receiveData(mappedData, { - targetComponentId: dataTransferConfig.targetComponentId, - targetComponentType: targetReceiver.componentType, - mode: dataTransferConfig.mode || "append", - mappingRules: dataTransferConfig.mappingRules || [], - }); + await targetReceiver.receiveData(mappedData, receiverConfig); toast.success(`${sourceData.length}개 항목이 전달되었습니다.`); - } else if (dataTransferConfig.targetType === "splitPanel") { + } else if (effectiveTargetType === "splitPanel") { // 🆕 분할 패널의 반대편 화면으로 전달 if (!splitPanelContext) { toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요."); @@ -1107,6 +1216,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 판단에 사용) @@ -1363,9 +1481,9 @@ export const ButtonPrimaryComponent: React.FC = ({ )}
- {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} + {/* 확인 다이얼로그 */} - + {getConfirmTitle()} {getConfirmMessage()} diff --git a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx index e8b0dba9..58554c9d 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx @@ -247,14 +247,12 @@ export const FileManagerModal: React.FC = ({
- {/* 파일 업로드 영역 - 높이 축소 */} - {!isDesignMode && ( + {/* 파일 업로드 영역 - readonly/disabled이면 숨김 */} + {!isDesignMode && !config.readonly && !config.disabled && (
{ - if (!config.disabled && !isDesignMode) { - fileInputRef.current?.click(); - } + fileInputRef.current?.click(); }} onDragOver={handleDragOver} onDragLeave={handleDragLeave} @@ -267,7 +265,6 @@ export const FileManagerModal: React.FC = ({ accept={config.accept} onChange={handleFileInputChange} className="hidden" - disabled={config.disabled} /> {uploading ? ( @@ -286,8 +283,8 @@ export const FileManagerModal: React.FC = ({ {/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
- {/* 좌측: 이미지 미리보기 (확대/축소 가능) */} -
+ {/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */} + {(config.showPreview !== false) &&
{/* 확대/축소 컨트롤 */} {selectedFile && previewImageUrl && (
@@ -369,10 +366,10 @@ export const FileManagerModal: React.FC = ({ {selectedFile.realFileName}
)} -
+
} - {/* 우측: 파일 목록 (고정 너비) */} -
+ {/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */} + {(config.showFileList !== false) &&

업로드된 파일

@@ -404,7 +401,7 @@ export const FileManagerModal: React.FC = ({ )}

- {formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()} + {config.showFileSize !== false && <>{formatFileSize(file.fileSize)} • }{file.fileExt.toUpperCase()}

@@ -434,19 +431,21 @@ export const FileManagerModal: React.FC = ({ > - - {!isDesignMode && ( + {config.allowDownload !== false && ( + + )} + {!isDesignMode && config.allowDelete !== false && (
)}
-
+
}
@@ -487,8 +486,8 @@ export const FileManagerModal: React.FC = ({ file={viewerFile} isOpen={isViewerOpen} onClose={handleViewerClose} - onDownload={onFileDownload} - onDelete={!isDesignMode ? onFileDelete : undefined} + onDownload={config.allowDownload !== false ? onFileDownload : undefined} + onDelete={!isDesignMode && config.allowDelete !== false ? onFileDelete : undefined} /> ); diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index fc39458a..de55bf2a 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -105,6 +105,8 @@ const FileUploadComponent: React.FC = ({ const [forceUpdate, setForceUpdate] = useState(0); const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); const fileInputRef = useRef(null); + // objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지) + const filesLoadedFromObjidRef = useRef(false); // 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리 const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); @@ -150,6 +152,7 @@ const FileUploadComponent: React.FC = ({ if (isRecordMode || !recordId) { setUploadedFiles([]); setRepresentativeImageUrl(null); + filesLoadedFromObjidRef.current = false; } } else if (prevIsRecordModeRef.current === null) { // 초기 마운트 시 모드 저장 @@ -191,63 +194,68 @@ const FileUploadComponent: React.FC = ({ }, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행 // 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드 - // 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지) + // 콤마로 구분된 다중 objid도 처리 (예: "123,456") const imageObjidFromFormData = formData?.[columnName]; useEffect(() => { - // 이미지 objid가 있고, 숫자 문자열인 경우에만 처리 - if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) { - const objidStr = String(imageObjidFromFormData); - - // 이미 같은 objid의 파일이 로드되어 있으면 스킵 - const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr); - if (alreadyLoaded) { - return; - } - - // 🔑 실제 파일 정보 조회 (previewUrl 제거 - apiClient blob 다운로드 방식으로 통일) - (async () => { - try { - const fileInfoResponse = await getFileInfoByObjid(objidStr); + if (!imageObjidFromFormData) return; + + const rawValue = String(imageObjidFromFormData); + // 콤마 구분 다중 objid 또는 단일 objid 모두 처리 + const objids = rawValue.split(',').map(s => s.trim()).filter(s => /^\d+$/.test(s)); + + if (objids.length === 0) return; + + // 모든 objid가 이미 로드되어 있으면 스킵 + const allLoaded = objids.every(id => uploadedFiles.some(f => String(f.objid) === id)); + if (allLoaded) return; + + (async () => { + try { + const loadedFiles: FileInfo[] = []; + + for (const objid of objids) { + // 이미 로드된 파일은 스킵 + if (uploadedFiles.some(f => String(f.objid) === objid)) continue; + + const fileInfoResponse = await getFileInfoByObjid(objid); if (fileInfoResponse.success && fileInfoResponse.data) { const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data; - const fileInfo = { - objid: objidStr, - realFileName: realFileName, - fileExt: fileExt, - fileSize: fileSize, - filePath: getFilePreviewUrl(objidStr), - regdate: regdate, + loadedFiles.push({ + objid, + realFileName, + fileExt, + fileSize, + filePath: getFilePreviewUrl(objid), + regdate, isImage: true, - isRepresentative: isRepresentative, - }; - - setUploadedFiles([fileInfo]); - // representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨 + isRepresentative, + } as FileInfo); } else { // 파일 정보 조회 실패 시 최소 정보로 추가 - console.warn("🖼️ [FileUploadComponent] 파일 정보 조회 실패, 최소 정보 사용"); - const minimalFileInfo = { - objid: objidStr, - realFileName: `image_${objidStr}.jpg`, + loadedFiles.push({ + objid, + realFileName: `file_${objid}`, fileExt: '.jpg', fileSize: 0, - filePath: getFilePreviewUrl(objidStr), + filePath: getFilePreviewUrl(objid), regdate: new Date().toISOString(), isImage: true, - }; - - setUploadedFiles([minimalFileInfo]); - // representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨 + } as FileInfo); } - } catch (error) { - console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); } - })(); - } - }, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존 + + if (loadedFiles.length > 0) { + setUploadedFiles(loadedFiles); + filesLoadedFromObjidRef.current = true; + } + } catch (error) { + console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); + } + })(); + }, [imageObjidFromFormData, columnName, component.id]); // 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너 // 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 @@ -365,6 +373,10 @@ const FileUploadComponent: React.FC = ({ ...file, })); + // 서버에서 0개 반환 + objid 기반 로딩이 이미 완료된 경우 덮어쓰지 않음 + if (formattedFiles.length === 0 && filesLoadedFromObjidRef.current) { + return false; + } // 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용) let finalFiles = formattedFiles; @@ -427,14 +439,19 @@ const FileUploadComponent: React.FC = ({ return; // DB 로드 성공 시 localStorage 무시 } - // 🆕 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지 + // objid 기반으로 이미 파일이 로드된 경우 빈 데이터로 덮어쓰지 않음 + if (filesLoadedFromObjidRef.current) { + return; + } + + // 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지 if (!isRecordMode || !recordId) { return; } // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) - // 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용) + // 전역 상태에서 최신 파일 정보 가져오기 (고유 키 사용) const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; const uniqueKeyForFallback = getUniqueKey(); const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || []; @@ -442,6 +459,10 @@ const FileUploadComponent: React.FC = ({ // 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성) const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles; + // 빈 데이터로 기존 파일을 덮어쓰지 않음 + if (currentFiles.length === 0) { + return; + } // 최신 파일과 현재 파일 비교 if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) { @@ -1147,8 +1168,8 @@ const FileUploadComponent: React.FC = ({ file={viewerFile} isOpen={isViewerOpen} onClose={handleViewerClose} - onDownload={handleFileDownload} - onDelete={!isDesignMode ? handleFileDelete : undefined} + onDownload={safeComponentConfig.allowDownload !== false ? handleFileDownload : undefined} + onDelete={!isDesignMode && safeComponentConfig.allowDelete !== false ? handleFileDelete : undefined} /> {/* 파일 관리 모달 */} diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx new file mode 100644 index 00000000..a8f752f9 --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx @@ -0,0 +1,544 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; +import { useToast } from "@/hooks/use-toast"; +import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types"; +import { defaultConfig } from "./config"; +import { useItemRouting } from "./hooks/useItemRouting"; + +export function ItemRoutingComponent({ + config: configProp, + isPreview, +}: ItemRoutingComponentProps) { + const { toast } = useToast(); + + const { + config, + items, + versions, + details, + loading, + selectedItemCode, + selectedItemName, + selectedVersionId, + fetchItems, + selectItem, + selectVersion, + refreshVersions, + refreshDetails, + deleteDetail, + deleteVersion, + setDefaultVersion, + unsetDefaultVersion, + } = useItemRouting(configProp || {}); + + const [searchText, setSearchText] = useState(""); + const [deleteTarget, setDeleteTarget] = useState<{ + type: "version" | "detail"; + id: string; + name: string; + } | null>(null); + + // 초기 로딩 (마운트 시 1회만) + const mountedRef = React.useRef(false); + useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + fetchItems(); + } + }, [fetchItems]); + + // 모달 저장 성공 감지 -> 데이터 새로고침 + const refreshVersionsRef = React.useRef(refreshVersions); + const refreshDetailsRef = React.useRef(refreshDetails); + refreshVersionsRef.current = refreshVersions; + refreshDetailsRef.current = refreshDetails; + + useEffect(() => { + const handleSaveSuccess = () => { + refreshVersionsRef.current(); + refreshDetailsRef.current(); + }; + window.addEventListener("saveSuccessInModal", handleSaveSuccess); + return () => { + window.removeEventListener("saveSuccessInModal", handleSaveSuccess); + }; + }, []); + + // 품목 검색 + const handleSearch = useCallback(() => { + fetchItems(searchText || undefined); + }, [fetchItems, searchText]); + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleSearch(); + }, + [handleSearch] + ); + + // 버전 추가 모달 + const handleAddVersion = useCallback(() => { + if (!selectedItemCode) { + toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" }); + return; + } + const screenId = config.modals.versionAddScreenId; + if (!screenId) return; + + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId, + urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable }, + splitPanelParentData: { + [config.dataSource.routingVersionFkColumn]: selectedItemCode, + }, + }, + }) + ); + }, [selectedItemCode, config, toast]); + + // 공정 추가 모달 + const handleAddProcess = useCallback(() => { + if (!selectedVersionId) { + toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" }); + return; + } + const screenId = config.modals.processAddScreenId; + if (!screenId) return; + + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId, + urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable }, + splitPanelParentData: { + [config.dataSource.routingDetailFkColumn]: selectedVersionId, + }, + }, + }) + ); + }, [selectedVersionId, config, toast]); + + // 공정 수정 모달 + const handleEditProcess = useCallback( + (detail: Record) => { + const screenId = config.modals.processEditScreenId; + if (!screenId) return; + + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId, + urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable }, + editData: detail, + }, + }) + ); + }, + [config] + ); + + // 기본 버전 토글 + const handleToggleDefault = useCallback( + async (versionId: string, currentIsDefault: boolean) => { + let success: boolean; + if (currentIsDefault) { + success = await unsetDefaultVersion(versionId); + if (success) toast({ title: "기본 버전이 해제되었습니다" }); + } else { + success = await setDefaultVersion(versionId); + if (success) toast({ title: "기본 버전으로 설정되었습니다" }); + } + if (!success) { + toast({ title: "기본 버전 변경 실패", variant: "destructive" }); + } + }, + [setDefaultVersion, unsetDefaultVersion, toast] + ); + + // 삭제 확인 + const handleConfirmDelete = useCallback(async () => { + if (!deleteTarget) return; + + let success = false; + if (deleteTarget.type === "version") { + success = await deleteVersion(deleteTarget.id); + } else { + success = await deleteDetail(deleteTarget.id); + } + + if (success) { + toast({ title: `${deleteTarget.name} 삭제 완료` }); + } else { + toast({ title: "삭제 실패", variant: "destructive" }); + } + setDeleteTarget(null); + }, [deleteTarget, deleteVersion, deleteDetail, toast]); + + const splitRatio = config.splitRatio || 40; + + if (isPreview) { + return ( +
+
+ +

+ 품목별 라우팅 관리 +

+

+ 품목 선택 - 라우팅 버전 - 공정 순서 +

+
+
+ ); + } + + return ( +
+
+ {/* 좌측 패널: 품목 목록 */} +
+
+

+ {config.leftPanelTitle || "품목 목록"} +

+
+ + {/* 검색 */} +
+ setSearchText(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="품목명/품번 검색" + className="h-8 text-xs" + /> + +
+ + {/* 품목 리스트 */} +
+ {items.length === 0 ? ( +
+

+ {loading ? "로딩 중..." : "품목이 없습니다"} +

+
+ ) : ( +
+ {items.map((item) => { + const itemCode = + item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number; + const itemName = + item[config.dataSource.itemNameColumn] || item.item_name; + const isSelected = selectedItemCode === itemCode; + + return ( + + ); + })} +
+ )} +
+
+ + {/* 우측 패널: 버전 + 공정 */} +
+ {selectedItemCode ? ( + <> + {/* 헤더: 선택된 품목 + 버전 추가 */} +
+
+

{selectedItemName}

+

{selectedItemCode}

+
+ {!config.readonly && ( + + )} +
+ + {/* 버전 선택 버튼들 */} + {versions.length > 0 ? ( +
+ 버전: + {versions.map((ver) => { + const isActive = selectedVersionId === ver.id; + const isDefault = ver.is_default === true; + return ( +
+ selectVersion(ver.id)} + > + {isDefault && } + {ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id} + + {!config.readonly && ( + <> + + + + )} +
+ ); + })} +
+ ) : ( +
+

+ 라우팅 버전이 없습니다. 버전을 추가해주세요. +

+
+ )} + + {/* 공정 테이블 */} + {selectedVersionId ? ( +
+ {/* 공정 테이블 헤더 */} +
+

+ {config.rightPanelTitle || "공정 순서"} ({details.length}건) +

+ {!config.readonly && ( + + )} +
+ + {/* 테이블 */} +
+ {details.length === 0 ? ( +
+

+ {loading ? "로딩 중..." : "등록된 공정이 없습니다"} +

+
+ ) : ( + + + + {config.processColumns.map((col) => ( + + {col.label} + + ))} + {!config.readonly && ( + + 관리 + + )} + + + + {details.map((detail) => ( + + {config.processColumns.map((col) => { + let cellValue = detail[col.name]; + if (cellValue == null) { + const aliasKey = Object.keys(detail).find( + (k) => k.endsWith(`_${col.name}`) + ); + if (aliasKey) cellValue = detail[aliasKey]; + } + return ( + + {cellValue ?? "-"} + + ); + })} + {!config.readonly && ( + +
+ + +
+
+ )} +
+ ))} +
+
+ )} +
+
+ ) : ( + versions.length > 0 && ( +
+

+ 라우팅 버전을 선택해주세요 +

+
+ ) + )} + + ) : ( +
+ +

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

+

+ 품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다 +

+
+ )} +
+
+ + {/* 삭제 확인 다이얼로그 */} + setDeleteTarget(null)}> + + + 삭제 확인 + + {deleteTarget?.name}을(를) 삭제하시겠습니까? + {deleteTarget?.type === "version" && ( + <> +
+ 해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다. + + )} +
+
+ + 취소 + + 삭제 + + +
+
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx new file mode 100644 index 00000000..f6fefd2e --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx @@ -0,0 +1,780 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Plus, Trash2, Check, ChevronsUpDown } 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 { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { ItemRoutingConfig, ProcessColumnDef } from "./types"; +import { defaultConfig } from "./config"; + +interface TableInfo { + tableName: string; + displayName?: string; +} + +interface ColumnInfo { + columnName: string; + displayName?: string; + dataType?: string; +} + +interface ScreenInfo { + screenId: number; + screenName: string; + screenCode: string; +} + +// 테이블 셀렉터 Combobox +function TableSelector({ + value, + onChange, + tables, + loading, +}: { + value: string; + onChange: (v: string) => void; + tables: TableInfo[]; + loading: boolean; +}) { + const [open, setOpen] = useState(false); + const selected = tables.find((t) => t.tableName === value); + + return ( + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + onChange(t.tableName); + setOpen(false); + }} + className="text-xs" + > + +
+ + {t.displayName || t.tableName} + + {t.displayName && ( + + {t.tableName} + + )} +
+
+ ))} +
+
+
+
+
+ ); +} + +// 컬럼 셀렉터 Combobox +function ColumnSelector({ + value, + onChange, + tableName, + label, +}: { + value: string; + onChange: (v: string) => void; + tableName: string; + label?: string; +}) { + const [open, setOpen] = useState(false); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!tableName) { + setColumns([]); + return; + } + const load = async () => { + setLoading(true); + try { + const { tableManagementApi } = await import( + "@/lib/api/tableManagement" + ); + const res = await tableManagementApi.getColumnList(tableName); + if (res.success && res.data?.columns) { + setColumns(res.data.columns); + } + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }; + load(); + }, [tableName]); + + const selected = columns.find((c) => c.columnName === value); + + return ( + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {columns.map((c) => ( + { + onChange(c.columnName); + setOpen(false); + }} + className="text-xs" + > + +
+ + {c.displayName || c.columnName} + + {c.displayName && ( + + {c.columnName} + + )} +
+
+ ))} +
+
+
+
+
+ ); +} + +// 화면 셀렉터 Combobox +function ScreenSelector({ + value, + onChange, +}: { + value?: number; + onChange: (v?: number) => void; +}) { + const [open, setOpen] = useState(false); + const [screens, setScreens] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const load = async () => { + setLoading(true); + try { + const { screenApi } = await import("@/lib/api/screen"); + const res = await screenApi.getScreens({ page: 1, size: 1000 }); + setScreens( + res.data.map((s: any) => ({ + screenId: s.screenId, + screenName: s.screenName, + screenCode: s.screenCode, + })) + ); + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }; + load(); + }, []); + + const selected = screens.find((s) => s.screenId === value); + + return ( + + + + + + + + + + 화면을 찾을 수 없습니다. + + + {screens.map((s) => ( + { + onChange(s.screenId === value ? undefined : s.screenId); + setOpen(false); + }} + className="text-xs" + > + +
+ {s.screenName} + + {s.screenCode} (ID: {s.screenId}) + +
+
+ ))} +
+
+
+
+
+ ); +} + +// 공정 테이블 컬럼 셀렉터 (routingDetailTable의 컬럼 목록에서 선택) +function ProcessColumnSelector({ + value, + onChange, + tableName, + processTable, +}: { + value: string; + onChange: (v: string) => void; + tableName: string; + processTable: string; +}) { + const [open, setOpen] = useState(false); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const loadAll = async () => { + if (!tableName) return; + setLoading(true); + try { + const { tableManagementApi } = await import( + "@/lib/api/tableManagement" + ); + const res = await tableManagementApi.getColumnList(tableName); + const cols: ColumnInfo[] = []; + if (res.success && res.data?.columns) { + cols.push(...res.data.columns); + } + if (processTable && processTable !== tableName) { + const res2 = await tableManagementApi.getColumnList(processTable); + if (res2.success && res2.data?.columns) { + cols.push( + ...res2.data.columns.map((c: any) => ({ + ...c, + columnName: c.columnName, + displayName: `[${processTable}] ${c.displayName || c.columnName}`, + })) + ); + } + } + setColumns(cols); + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }; + loadAll(); + }, [tableName, processTable]); + + const selected = columns.find((c) => c.columnName === value); + + return ( + + + + + + + + + + 없음 + + + {columns.map((c) => ( + { + onChange(c.columnName); + setOpen(false); + }} + className="text-xs" + > + + {c.displayName || c.columnName} + + ))} + + + + + + ); +} + +interface ConfigPanelProps { + config: Partial; + onChange: (config: Partial) => void; +} + +export function ItemRoutingConfigPanel({ + config: configProp, + onChange, +}: ConfigPanelProps) { + const config: ItemRoutingConfig = { + ...defaultConfig, + ...configProp, + dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource }, + modals: { ...defaultConfig.modals, ...configProp?.modals }, + processColumns: configProp?.processColumns?.length + ? configProp.processColumns + : defaultConfig.processColumns, + }; + + const [allTables, setAllTables] = useState([]); + const [tablesLoading, setTablesLoading] = useState(false); + + useEffect(() => { + const load = async () => { + setTablesLoading(true); + try { + const { tableManagementApi } = await import( + "@/lib/api/tableManagement" + ); + const res = await tableManagementApi.getTableList(); + if (res.success && res.data) { + setAllTables(res.data); + } + } catch { + /* ignore */ + } finally { + setTablesLoading(false); + } + }; + load(); + }, []); + + const update = (partial: Partial) => { + onChange({ ...configProp, ...partial }); + }; + + const updateDataSource = (field: string, value: string) => { + update({ dataSource: { ...config.dataSource, [field]: value } }); + }; + + const updateModals = (field: string, value: number | undefined) => { + update({ modals: { ...config.modals, [field]: value } }); + }; + + // 컬럼 관리 + const addColumn = () => { + update({ + processColumns: [ + ...config.processColumns, + { name: "", label: "새 컬럼", width: 100 }, + ], + }); + }; + + const removeColumn = (idx: number) => { + update({ + processColumns: config.processColumns.filter((_, i) => i !== idx), + }); + }; + + const updateColumn = ( + idx: number, + field: keyof ProcessColumnDef, + value: any + ) => { + const next = [...config.processColumns]; + next[idx] = { ...next[idx], [field]: value }; + update({ processColumns: next }); + }; + + return ( +
+

품목별 라우팅 설정

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

+ 데이터 소스 +

+ +
+ + updateDataSource("itemTable", v)} + tables={allTables} + loading={tablesLoading} + /> +
+
+
+ + updateDataSource("itemNameColumn", v)} + tableName={config.dataSource.itemTable} + label="품목명" + /> +
+
+ + updateDataSource("itemCodeColumn", v)} + tableName={config.dataSource.itemTable} + label="품목코드" + /> +
+
+ +
+ + updateDataSource("routingVersionTable", v)} + tables={allTables} + loading={tablesLoading} + /> +
+
+
+ + updateDataSource("routingVersionFkColumn", v)} + tableName={config.dataSource.routingVersionTable} + label="FK 컬럼" + /> +
+
+ + + updateDataSource("routingVersionNameColumn", v) + } + tableName={config.dataSource.routingVersionTable} + label="버전명" + /> +
+
+ +
+ + updateDataSource("routingDetailTable", v)} + tables={allTables} + loading={tablesLoading} + /> +
+
+ + updateDataSource("routingDetailFkColumn", v)} + tableName={config.dataSource.routingDetailTable} + label="FK 컬럼" + /> +
+ +
+ + updateDataSource("processTable", v)} + tables={allTables} + loading={tablesLoading} + /> +
+
+
+ + updateDataSource("processNameColumn", v)} + tableName={config.dataSource.processTable} + label="공정명" + /> +
+
+ + updateDataSource("processCodeColumn", v)} + tableName={config.dataSource.processTable} + label="공정코드" + /> +
+
+
+ + {/* 모달 설정 */} +
+

모달 연동

+ +
+ + updateModals("versionAddScreenId", v)} + /> +
+
+ + updateModals("processAddScreenId", v)} + /> +
+
+ + updateModals("processEditScreenId", v)} + /> +
+
+ + {/* 공정 테이블 컬럼 설정 */} +
+
+

+ 공정 테이블 컬럼 +

+ +
+ +
+ {config.processColumns.map((col, idx) => ( +
+ updateColumn(idx, "name", v)} + tableName={config.dataSource.routingDetailTable} + processTable={config.dataSource.processTable} + /> + updateColumn(idx, "label", e.target.value)} + className="h-7 flex-1 text-[10px]" + placeholder="표시명" + /> + + updateColumn( + idx, + "width", + e.target.value ? Number(e.target.value) : undefined + ) + } + className="h-7 w-14 text-[10px]" + placeholder="너비" + /> + +
+ ))} +
+
+ + {/* UI 설정 */} +
+

UI 설정

+ +
+ + update({ splitRatio: Number(e.target.value) })} + min={20} + max={60} + className="mt-1 h-8 w-20 text-xs" + /> +
+ +
+ + update({ leftPanelTitle: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+
+ + update({ rightPanelTitle: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+ +
+ + update({ versionAddButtonText: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+
+ + update({ processAddButtonText: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+ +
+ update({ autoSelectFirstVersion: v })} + /> + +
+ +
+ update({ readonly: v })} + /> + +
+
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx new file mode 100644 index 00000000..7a9fa624 --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx @@ -0,0 +1,32 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2ItemRoutingDefinition } from "./index"; +import { ItemRoutingComponent } from "./ItemRoutingComponent"; + +export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2ItemRoutingDefinition; + + render(): React.ReactElement { + const { formData, isPreview, config, tableName } = this.props as Record< + string, + unknown + >; + + return ( + } + tableName={tableName as string} + isPreview={isPreview as boolean} + /> + ); + } +} + +ItemRoutingRenderer.registerSelf(); + +if (process.env.NODE_ENV === "development") { + ItemRoutingRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/v2-item-routing/config.ts b/frontend/lib/registry/components/v2-item-routing/config.ts new file mode 100644 index 00000000..a84ff23e --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/config.ts @@ -0,0 +1,38 @@ +import { ItemRoutingConfig } from "./types"; + +export const defaultConfig: ItemRoutingConfig = { + dataSource: { + itemTable: "item_info", + itemNameColumn: "item_name", + itemCodeColumn: "item_number", + routingVersionTable: "item_routing_version", + routingVersionFkColumn: "item_code", + routingVersionNameColumn: "version_name", + routingDetailTable: "item_routing_detail", + routingDetailFkColumn: "routing_version_id", + processTable: "process_mng", + processNameColumn: "process_name", + processCodeColumn: "process_code", + }, + modals: { + versionAddScreenId: 1613, + processAddScreenId: 1614, + processEditScreenId: 1615, + }, + processColumns: [ + { name: "seq_no", label: "순서", width: 60, align: "center" }, + { name: "process_code", label: "공정코드", width: 120 }, + { name: "work_type", label: "작업유형", width: 100 }, + { name: "standard_time", label: "표준시간(분)", width: 100, align: "right" }, + { name: "is_required", label: "필수여부", width: 80, align: "center" }, + { name: "is_fixed_order", label: "순서고정", width: 80, align: "center" }, + { name: "outsource_supplier", label: "외주업체", width: 120 }, + ], + splitRatio: 40, + leftPanelTitle: "품목 목록", + rightPanelTitle: "공정 순서", + readonly: false, + autoSelectFirstVersion: true, + versionAddButtonText: "+ 라우팅 버전 추가", + processAddButtonText: "+ 공정 추가", +}; diff --git a/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts b/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts new file mode 100644 index 00000000..97f6be4f --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts @@ -0,0 +1,293 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { apiClient } from "@/lib/api/client"; +import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData } from "../types"; +import { defaultConfig } from "../config"; + +const API_BASE = "/process-work-standard"; + +export function useItemRouting(configPartial: Partial) { + const configKey = useMemo( + () => JSON.stringify(configPartial), + [configPartial] + ); + + const config: ItemRoutingConfig = useMemo(() => ({ + ...defaultConfig, + ...configPartial, + dataSource: { ...defaultConfig.dataSource, ...configPartial?.dataSource }, + modals: { ...defaultConfig.modals, ...configPartial?.modals }, + processColumns: configPartial?.processColumns?.length + ? configPartial.processColumns + : defaultConfig.processColumns, + }), [configKey]); + + const configRef = useRef(config); + configRef.current = config; + + const [items, setItems] = useState([]); + const [versions, setVersions] = useState([]); + const [details, setDetails] = useState([]); + const [loading, setLoading] = useState(false); + + // 선택 상태 + const [selectedItemCode, setSelectedItemCode] = useState(null); + const [selectedItemName, setSelectedItemName] = useState(null); + const [selectedVersionId, setSelectedVersionId] = useState(null); + + // 품목 목록 조회 + const fetchItems = useCallback( + async (search?: string) => { + try { + setLoading(true); + const ds = configRef.current.dataSource; + const params = new URLSearchParams({ + tableName: ds.itemTable, + nameColumn: ds.itemNameColumn, + codeColumn: ds.itemCodeColumn, + routingTable: ds.routingVersionTable, + routingFkColumn: ds.routingVersionFkColumn, + ...(search ? { search } : {}), + }); + const res = await apiClient.get(`${API_BASE}/items?${params}`); + if (res.data?.success) { + setItems(res.data.data || []); + } + } catch (err) { + console.error("품목 조회 실패", err); + } finally { + setLoading(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey] + ); + + // 라우팅 버전 목록 조회 + const fetchVersions = useCallback( + async (itemCode: string) => { + try { + const ds = configRef.current.dataSource; + const params = new URLSearchParams({ + routingVersionTable: ds.routingVersionTable, + routingDetailTable: ds.routingDetailTable, + routingFkColumn: ds.routingVersionFkColumn, + processTable: ds.processTable, + processNameColumn: ds.processNameColumn, + processCodeColumn: ds.processCodeColumn, + }); + const res = await apiClient.get( + `${API_BASE}/items/${encodeURIComponent(itemCode)}/routings?${params}` + ); + if (res.data?.success) { + const routingData = res.data.data || []; + setVersions(routingData); + return routingData; + } + } catch (err) { + console.error("라우팅 버전 조회 실패", err); + } + return []; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey] + ); + + // 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함 + const fetchDetails = useCallback( + async (versionId: string) => { + try { + setLoading(true); + const ds = configRef.current.dataSource; + const searchConditions = { + [ds.routingDetailFkColumn]: { value: versionId, operator: "equals" }, + }; + const params = new URLSearchParams({ + page: "1", + size: "1000", + search: JSON.stringify(searchConditions), + sortBy: "seq_no", + sortOrder: "ASC", + enableEntityJoin: "true", + }); + const res = await apiClient.get( + `/table-management/tables/${ds.routingDetailTable}/data-with-joins?${params}` + ); + if (res.data?.success) { + const result = res.data.data; + setDetails(Array.isArray(result) ? result : result?.data || []); + } + } catch (err) { + console.error("공정 상세 조회 실패", err); + } finally { + setLoading(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey] + ); + + // 품목 선택 + const selectItem = useCallback( + async (itemCode: string, itemName: string) => { + setSelectedItemCode(itemCode); + setSelectedItemName(itemName); + setSelectedVersionId(null); + setDetails([]); + + const versionList = await fetchVersions(itemCode); + + if (versionList.length > 0) { + // 기본 버전 우선, 없으면 첫번째 버전 선택 + const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default); + const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null); + if (targetVersion) { + setSelectedVersionId(targetVersion.id); + await fetchDetails(targetVersion.id); + } + } + }, + [fetchVersions, fetchDetails] + ); + + // 버전 선택 + const selectVersion = useCallback( + async (versionId: string) => { + setSelectedVersionId(versionId); + await fetchDetails(versionId); + }, + [fetchDetails] + ); + + // 모달에서 데이터 변경 후 새로고침 + const refreshVersions = useCallback(async () => { + if (selectedItemCode) { + const versionList = await fetchVersions(selectedItemCode); + if (selectedVersionId) { + await fetchDetails(selectedVersionId); + } else if (versionList.length > 0) { + const lastVersion = versionList[versionList.length - 1]; + setSelectedVersionId(lastVersion.id); + await fetchDetails(lastVersion.id); + } + } + }, [selectedItemCode, selectedVersionId, fetchVersions, fetchDetails]); + + const refreshDetails = useCallback(async () => { + if (selectedVersionId) { + await fetchDetails(selectedVersionId); + } + }, [selectedVersionId, fetchDetails]); + + // 공정 삭제 + const deleteDetail = useCallback( + async (detailId: string) => { + try { + const ds = configRef.current.dataSource; + const res = await apiClient.delete( + `/table-management/tables/${ds.routingDetailTable}/delete`, + { data: [{ id: detailId }] } + ); + if (res.data?.success) { + await refreshDetails(); + return true; + } + } catch (err) { + console.error("공정 삭제 실패", err); + } + return false; + }, + [refreshDetails] + ); + + // 버전 삭제 + const deleteVersion = useCallback( + async (versionId: string) => { + try { + const ds = configRef.current.dataSource; + const res = await apiClient.delete( + `/table-management/tables/${ds.routingVersionTable}/delete`, + { data: [{ id: versionId }] } + ); + if (res.data?.success) { + if (selectedVersionId === versionId) { + setSelectedVersionId(null); + setDetails([]); + } + await refreshVersions(); + return true; + } + } catch (err) { + console.error("버전 삭제 실패", err); + } + return false; + }, + [selectedVersionId, refreshVersions] + ); + + // 기본 버전 설정 + const setDefaultVersion = useCallback( + async (versionId: string) => { + try { + const ds = configRef.current.dataSource; + const res = await apiClient.put(`${API_BASE}/versions/${versionId}/set-default`, { + routingVersionTable: ds.routingVersionTable, + routingFkColumn: ds.routingVersionFkColumn, + }); + if (res.data?.success) { + if (selectedItemCode) { + await fetchVersions(selectedItemCode); + } + return true; + } + } catch (err) { + console.error("기본 버전 설정 실패", err); + } + return false; + }, + [selectedItemCode, fetchVersions] + ); + + // 기본 버전 해제 + const unsetDefaultVersion = useCallback( + async (versionId: string) => { + try { + const ds = configRef.current.dataSource; + const res = await apiClient.put(`${API_BASE}/versions/${versionId}/unset-default`, { + routingVersionTable: ds.routingVersionTable, + }); + if (res.data?.success) { + if (selectedItemCode) { + await fetchVersions(selectedItemCode); + } + return true; + } + } catch (err) { + console.error("기본 버전 해제 실패", err); + } + return false; + }, + [selectedItemCode, fetchVersions] + ); + + return { + config, + items, + versions, + details, + loading, + selectedItemCode, + selectedItemName, + selectedVersionId, + fetchItems, + selectItem, + selectVersion, + refreshVersions, + refreshDetails, + deleteDetail, + deleteVersion, + setDefaultVersion, + unsetDefaultVersion, + }; +} diff --git a/frontend/lib/registry/components/v2-item-routing/index.ts b/frontend/lib/registry/components/v2-item-routing/index.ts new file mode 100644 index 00000000..1ccd3c7a --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/index.ts @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { ItemRoutingComponent } from "./ItemRoutingComponent"; +import { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel"; +import { defaultConfig } from "./config"; + +export const V2ItemRoutingDefinition = createComponentDefinition({ + id: "v2-item-routing", + name: "품목별 라우팅", + nameEng: "Item Routing", + description: "품목별 라우팅 버전과 공정 순서를 관리하는 3단계 계층 컴포넌트", + category: ComponentCategory.INPUT, + webType: "component", + component: ItemRoutingComponent, + defaultConfig: defaultConfig, + defaultSize: { + width: 1400, + height: 800, + gridColumnSpan: "12", + }, + configPanel: ItemRoutingConfigPanel, + icon: "ListOrdered", + tags: ["라우팅", "공정", "품목", "버전", "제조", "생산"], + version: "1.0.0", + author: "개발팀", + documentation: ` +품목별 라우팅 버전과 공정 순서를 관리하는 전용 컴포넌트입니다. + +## 주요 기능 +- 좌측: 품목 목록 검색 및 선택 +- 우측 상단: 라우팅 버전 선택 (Badge 버튼) 및 추가/삭제 +- 우측 하단: 선택된 버전의 공정 순서 테이블 (추가/수정/삭제) +- 기존 모달 화면 재활용 (1613, 1614, 1615) + +## 커스터마이징 +- 데이터 소스 테이블/컬럼 변경 가능 +- 모달 화면 ID 변경 가능 +- 공정 테이블 컬럼 추가/삭제 가능 +- 좌우 분할 비율, 패널 제목, 버튼 텍스트 변경 가능 +- 읽기 전용 모드 지원 + `, +}); + +export type { + ItemRoutingConfig, + ItemRoutingComponentProps, + ItemRoutingDataSource, + ItemRoutingModals, + ProcessColumnDef, +} from "./types"; + +export { ItemRoutingComponent } from "./ItemRoutingComponent"; +export { ItemRoutingRenderer } from "./ItemRoutingRenderer"; +export { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel"; diff --git a/frontend/lib/registry/components/v2-item-routing/types.ts b/frontend/lib/registry/components/v2-item-routing/types.ts new file mode 100644 index 00000000..06b108da --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/types.ts @@ -0,0 +1,78 @@ +/** + * 품목별 라우팅 관리 컴포넌트 타입 정의 + * + * 3단계 계층: item_info → item_routing_version → item_routing_detail + */ + +// 데이터 소스 설정 +export interface ItemRoutingDataSource { + itemTable: string; + itemNameColumn: string; + itemCodeColumn: string; + routingVersionTable: string; + routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK + routingVersionNameColumn: string; + routingDetailTable: string; + routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK + processTable: string; + processNameColumn: string; + processCodeColumn: string; +} + +// 모달 연동 설정 +export interface ItemRoutingModals { + versionAddScreenId?: number; + processAddScreenId?: number; + processEditScreenId?: number; +} + +// 공정 테이블 컬럼 정의 +export interface ProcessColumnDef { + name: string; + label: string; + width?: number; + align?: "left" | "center" | "right"; +} + +// 전체 Config +export interface ItemRoutingConfig { + dataSource: ItemRoutingDataSource; + modals: ItemRoutingModals; + processColumns: ProcessColumnDef[]; + splitRatio?: number; + leftPanelTitle?: string; + rightPanelTitle?: string; + readonly?: boolean; + autoSelectFirstVersion?: boolean; + versionAddButtonText?: string; + processAddButtonText?: string; +} + +// 컴포넌트 Props +export interface ItemRoutingComponentProps { + config: Partial; + formData?: Record; + isPreview?: boolean; + tableName?: string; +} + +// 데이터 모델 +export interface ItemData { + id: string; + [key: string]: any; +} + +export interface RoutingVersionData { + id: string; + version_name: string; + is_default?: boolean; + [key: string]: any; +} + +export interface RoutingDetailData { + id: string; + routing_version_id: string; + seq_no: string; + process_code: string; + [key: string]: any; +} 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..cf7b306f --- /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, + selectedWorkItemIdByPhase, + selectedDetailsByPhase, + 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, phaseKey: string) => { + fetchWorkItemDetails(workItemId, phaseKey); + }, + [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/DetailFormModal.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx new file mode 100644 index 00000000..d9828aa0 --- /dev/null +++ b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx @@ -0,0 +1,445 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { WorkItemDetail, DetailTypeDefinition, InspectionStandard } from "../types"; +import { InspectionStandardLookup } from "./InspectionStandardLookup"; + +interface DetailFormModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: Partial) => void; + detailTypes: DetailTypeDefinition[]; + editData?: WorkItemDetail | null; + mode: "add" | "edit"; +} + +const LOOKUP_TARGETS = [ + { value: "equipment", label: "설비정보" }, + { value: "material", label: "자재정보" }, + { value: "worker", label: "작업자정보" }, + { value: "tool", label: "공구정보" }, + { value: "document", label: "문서정보" }, +]; + +const INPUT_TYPES = [ + { value: "text", label: "텍스트" }, + { value: "number", label: "숫자" }, + { value: "date", label: "날짜" }, + { value: "textarea", label: "장문텍스트" }, + { value: "select", label: "선택형" }, +]; + +export function DetailFormModal({ + open, + onClose, + onSubmit, + detailTypes, + editData, + mode, +}: DetailFormModalProps) { + const [formData, setFormData] = useState>({}); + const [inspectionLookupOpen, setInspectionLookupOpen] = useState(false); + const [selectedInspection, setSelectedInspection] = useState(null); + + useEffect(() => { + if (open) { + if (mode === "edit" && editData) { + setFormData({ ...editData }); + if (editData.inspection_code) { + setSelectedInspection({ + id: "", + inspection_code: editData.inspection_code, + inspection_item: editData.content || "", + inspection_method: editData.inspection_method || "", + unit: editData.unit || "", + lower_limit: editData.lower_limit || "", + upper_limit: editData.upper_limit || "", + }); + } + } else { + setFormData({ + detail_type: detailTypes[0]?.value || "", + content: "", + is_required: "Y", + }); + setSelectedInspection(null); + } + } + }, [open, mode, editData, detailTypes]); + + const updateField = (field: string, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleInspectionSelect = (item: InspectionStandard) => { + setSelectedInspection(item); + setFormData((prev) => ({ + ...prev, + inspection_code: item.inspection_code, + content: item.inspection_item, + inspection_method: item.inspection_method, + unit: item.unit, + lower_limit: item.lower_limit || "", + upper_limit: item.upper_limit || "", + })); + }; + + const handleSubmit = () => { + if (!formData.detail_type) return; + + const type = formData.detail_type; + + if (type === "check" && !formData.content?.trim()) return; + if (type === "inspect" && !formData.content?.trim()) return; + if (type === "procedure" && !formData.content?.trim()) return; + if (type === "input" && !formData.content?.trim()) return; + if (type === "info" && !formData.lookup_target) return; + + onSubmit(formData); + onClose(); + }; + + const currentType = formData.detail_type || ""; + + return ( + <> + !v && onClose()}> + + + + 상세 항목 {mode === "add" ? "추가" : "수정"} + + + 상세 항목의 유형을 선택하고 내용을 입력하세요 + + + +
+ {/* 유형 선택 */} +
+ + +
+ + {/* 체크리스트 */} + {currentType === "check" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 전원 상태 확인" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + )} + + {/* 검사항목 */} + {currentType === "inspect" && ( + <> +
+ +
+ + +
+
+ + {selectedInspection && ( +
+

+ 선택된 검사기준 정보 +

+
+

+ 검사코드: {selectedInspection.inspection_code} +

+

+ 검사항목: {selectedInspection.inspection_item} +

+

+ 검사방법: {selectedInspection.inspection_method || "-"} +

+

+ 단위: {selectedInspection.unit || "-"} +

+

+ 하한값: {selectedInspection.lower_limit || "-"} +

+

+ 상한값: {selectedInspection.upper_limit || "-"} +

+
+
+ )} + +
+ + updateField("content", e.target.value)} + placeholder="예: 외경 치수" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+
+ + updateField("inspection_method", e.target.value)} + placeholder="예: 마이크로미터" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + updateField("unit", e.target.value)} + placeholder="예: mm" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+
+ + updateField("lower_limit", e.target.value)} + placeholder="예: 7.95" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + updateField("upper_limit", e.target.value)} + placeholder="예: 8.05" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + )} + + {/* 작업절차 */} + {currentType === "procedure" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 자재 투입" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + updateField( + "duration_minutes", + e.target.value ? Number(e.target.value) : undefined + ) + } + placeholder="예: 5" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + )} + + {/* 직접입력 */} + {currentType === "input" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 작업자 의견" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+ + )} + + {/* 정보조회 */} + {currentType === "info" && ( + <> +
+ + +
+
+ + updateField("display_fields", e.target.value)} + placeholder="예: 설비명, 설비코드" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + )} + + {/* 필수 여부 (모든 유형 공통) */} + {currentType && ( +
+ + +
+ )} +
+ + + + + +
+
+ + setInspectionLookupOpen(false)} + onSelect={handleInspectionSelect} + /> + + ); +} diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/InspectionStandardLookup.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/InspectionStandardLookup.tsx new file mode 100644 index 00000000..75094d58 --- /dev/null +++ b/frontend/lib/registry/components/v2-process-work-standard/components/InspectionStandardLookup.tsx @@ -0,0 +1,187 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Search, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { apiClient } from "@/lib/api/client"; +import { InspectionStandard } from "../types"; + +interface InspectionStandardLookupProps { + open: boolean; + onClose: () => void; + onSelect: (item: InspectionStandard) => void; +} + +export function InspectionStandardLookup({ + open, + onClose, + onSelect, +}: InspectionStandardLookupProps) { + const [data, setData] = useState([]); + const [searchText, setSearchText] = useState(""); + const [loading, setLoading] = useState(false); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const search: Record = {}; + if (searchText.trim()) { + search.inspection_item = searchText.trim(); + search.inspection_code = searchText.trim(); + } + const params = new URLSearchParams({ + page: "1", + size: "100", + enableEntityJoin: "true", + ...(searchText.trim() ? { search: JSON.stringify(search) } : {}), + }); + const res = await apiClient.get( + `/table-management/tables/inspection_standard/data-with-joins?${params}` + ); + if (res.data?.success) { + const result = res.data.data; + setData(Array.isArray(result) ? result : result?.data || []); + } + } catch (err) { + console.error("검사기준 조회 실패", err); + } finally { + setLoading(false); + } + }, [searchText]); + + useEffect(() => { + if (open) { + fetchData(); + } + }, [open, fetchData]); + + const handleSelect = (item: any) => { + onSelect({ + id: item.id, + inspection_code: item.inspection_code || "", + inspection_item: item.inspection_item || item.inspection_criteria || "", + inspection_method: item.inspection_method || "", + unit: item.unit || "", + lower_limit: item.lower_limit || "", + upper_limit: item.upper_limit || "", + }); + onClose(); + }; + + return ( + !v && onClose()}> + + + + + 검사기준 조회 + + + 검사기준을 검색하여 선택하세요 + + + +
+
+ setSearchText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && fetchData()} + className="h-9 text-sm" + /> +
+ +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : data.length === 0 ? ( + + + + ) : ( + data.map((item, idx) => ( + + + + + + + + + + )) + )} + +
+ 검사코드 + + 검사항목 + + 검사방법 + + 하한 + + 상한 + + 단위 + + 선택 +
+ 조회 중... +
+ 검사기준이 없습니다 +
{item.inspection_code || "-"} + {item.inspection_item || item.inspection_criteria || "-"} + {item.inspection_method || "-"} + {item.lower_limit || "-"} + + {item.upper_limit || "-"} + {item.unit || "-"} + +
+
+
+ + + + +
+
+ ); +} 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..689d006d --- /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..e9f97c02 --- /dev/null +++ b/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemAddModal.tsx @@ -0,0 +1,350 @@ +"use client"; + +import React, { useState, useEffect } 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(""); + const [isRequired, setIsRequired] = useState("Y"); + const [description, setDescription] = useState(""); + const [details, setDetails] = useState([]); + + useEffect(() => { + if (open && editItem) { + setTitle(editItem.title || ""); + setIsRequired(editItem.is_required || "Y"); + setDescription(editItem.description || ""); + } else if (open && !editItem) { + setTitle(""); + setIsRequired("Y"); + setDescription(""); + setDetails([]); + } + }, [open, editItem]); + + 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" + /> +
+
+ + +
+
+
+ +