From 7a97603106055f377f976384b850eaf7903c61bf Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 25 Feb 2026 17:03:47 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat(pop-card-list):=203=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EB=B6=84=EB=A6=AC=20+=20=ED=8F=AC=EC=9E=A5=202?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EA=B3=84=EC=82=B0=EA=B8=B0=20+=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 입력 필드/포장등록/담기 버튼 독립 ON/OFF 분리 - NumberInputModal을 4단계 상태 머신으로 재작성 (수량 -> 포장 수 -> 개당 수량 -> summary) - 포장 단위 커스텀 지원 (기본 6종 + 디자이너 추가) - 본문 필드에 계산식 통합 (3-드롭다운 수식 빌더) - 입력 필드: limitColumn(동적 상한), saveTable/saveColumn(저장 대상) - 저장 대상 테이블 선택을 TableCombobox로 교체 (검색 가능) - 다중 정렬 지원 + 하위 호환 (sorts.map 에러 수정) - GroupedColumnSelect 항상 테이블명 헤더 표시 - 반응형 표시 우선순위 (required/shrink/hidden) 설정 - PackageEntry/CartItem 타입 확장, CardPackageConfig 신규 Co-authored-by: Cursor --- .gitignore | 3 +- PLAN.MD | 733 +++---- STATUS.md | 6 +- .../designer/panels/ComponentEditorPanel.tsx | 1 + .../pop-card-list/NumberInputModal.tsx | 473 +++-- .../pop-card-list/PackageUnitModal.tsx | 34 +- .../pop-card-list/PopCardListComponent.tsx | 472 +++-- .../pop-card-list/PopCardListConfig.tsx | 1795 +++++++++++------ .../pop-components/pop-card-list/index.tsx | 9 + frontend/lib/registry/pop-components/types.ts | 116 +- 10 files changed, 2173 insertions(+), 1469 deletions(-) diff --git a/.gitignore b/.gitignore index 0194c053..18a191ed 100644 --- a/.gitignore +++ b/.gitignore @@ -292,4 +292,5 @@ uploads/ claude.md # 개인 작업 문서 (popdocs) -popdocs/ \ No newline at end of file +popdocs/ +.cursor/rules/popdocs-safety.mdc \ No newline at end of file diff --git a/PLAN.MD b/PLAN.MD index 45468fa4..49d2d7e4 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,404 +1,202 @@ -# 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성 +# 현재 구현 계획: pop-card-list 입력 필드/계산 필드 구조 개편 -> **작성일**: 2026-02-10 -> **상태**: 코딩 완료 (방어 로직 패치 포함) -> **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성 +> **작성일**: 2026-02-24 +> **상태**: 계획 완료, 코딩 대기 +> **목적**: 입력 필드 설정 단순화 + 본문 필드에 계산식 통합 + 기존 계산 필드 섹션 제거 --- -## 1. 문제 요약 +## 1. 변경 개요 -pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가. +### 배경 +- 기존: "입력 필드", "계산 필드", "담기 버튼" 3개가 별도 섹션으로 분리 +- 문제: 계산 필드가 본문 필드와 동일한 위치에 표시되어야 하는데 별도 영역에 있음 +- 문제: 입력 필드의 min/max 고정값은 비실용적 (실제로는 DB 컬럼 기준 제한이 필요) +- 문제: step, columnName, sourceColumns, resultColumn 등 죽은 코드 존재 -| # | 문제 | 심각도 | 영향 | -|---|------|--------|------| -| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 | -| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 | -| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 | -| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 | +### 목표 +1. **본문 필드에 계산식 지원 추가** - 필드별로 "DB 컬럼" 또는 "계산식" 선택 +2. **입력 필드 설정 단순화** - 고정 min/max 제거, 제한 기준 컬럼 방식으로 변경 +3. **기존 "계산 필드" 섹션 제거** - 본문 필드에 통합되므로 불필요 +4. **죽은 코드 정리** --- -## 2. 수정 대상 파일 (2개) +## 2. 수정 대상 파일 (3개) -### 파일 A: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx` +### 파일 A: `frontend/lib/registry/pop-components/types.ts` -**변경 유형**: 설정 UI 추가 3건 +#### 변경 A-1: CardFieldBinding 타입 확장 -#### 변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래) - -집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가. - -**추가할 위치**: `{/* 집계 함수 + 대상 컬럼 */}` 블록 다음, `{/* 자동 새로고침 */}` 블록 이전 - -**추가할 코드** (약 50줄): - -```tsx -{/* 그룹핑 (차트용 X축 분류) */} -{dataSource.aggregation && ( -
- - - - - - - - - - - 컬럼을 찾을 수 없습니다. - - - {columns.map((col) => ( - { - const current = dataSource.aggregation?.groupBy ?? []; - const isSelected = current.includes(col.name); - const newGroupBy = isSelected - ? current.filter((g) => g !== col.name) - : [...current, col.name]; - onChange({ - ...dataSource, - aggregation: { - ...dataSource.aggregation!, - groupBy: newGroupBy.length > 0 ? newGroupBy : undefined, - }, - }); - setGroupByOpen(false); - }} - className="text-xs" - > - - {col.name} - ({col.type}) - - ))} - - - - - -

- 차트에서 X축 카테고리로 사용됩니다 -

-
-)} -``` - -**필요한 state 추가** (DataSourceEditor 내부, 기존 state 옆): - -```tsx -const [groupByOpen, setGroupByOpen] = useState(false); -``` - -#### 변경 A-2: 차트 타입 설정에 xAxisColumn/yAxisColumn 추가 (라인 1187~1212 부근) - -**추가할 위치**: `{item.subType === "chart" && (` 블록 내부, 차트 유형 Select 다음 - -**추가할 코드** (약 30줄): - -```tsx -{/* X축 컬럼 */} -
- - - onUpdate({ - ...item, - chartConfig: { - ...item.chartConfig, - chartType: item.chartConfig?.chartType ?? "bar", - xAxisColumn: e.target.value || undefined, - }, - }) - } - placeholder="groupBy 컬럼명 (비우면 자동)" - className="h-8 text-xs" - /> -

- 그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용 -

-
-``` - -#### 변경 A-3: 통계 카드 타입에 카테고리 설정 UI 추가 (라인 1272 부근, 게이지 설정 다음) - -**추가할 위치**: `{item.subType === "gauge" && (` 블록 다음에 새 블록 추가 - -**추가할 코드** (약 100줄): `StatCategoryEditor` 인라인 블록 - -```tsx -{item.subType === "stat-card" && ( -
-
- - -
- - {(item.statConfig?.categories ?? []).map((cat, catIdx) => ( -
-
- { - const newCats = [...(item.statConfig?.categories ?? [])]; - newCats[catIdx] = { ...cat, label: e.target.value }; - onUpdate({ - ...item, - statConfig: { ...item.statConfig, categories: newCats }, - }); - }} - placeholder="라벨 (예: 수주)" - className="h-6 flex-1 text-xs" - /> - { - const newCats = [...(item.statConfig?.categories ?? [])]; - newCats[catIdx] = { ...cat, color: e.target.value || undefined }; - onUpdate({ - ...item, - statConfig: { ...item.statConfig, categories: newCats }, - }); - }} - placeholder="#색상코드" - className="h-6 w-20 text-xs" - /> - -
- {/* 필터 조건: 컬럼 / 연산자 / 값 */} -
- { - const newCats = [...(item.statConfig?.categories ?? [])]; - newCats[catIdx] = { - ...cat, - filter: { ...cat.filter, column: e.target.value }, - }; - onUpdate({ - ...item, - statConfig: { ...item.statConfig, categories: newCats }, - }); - }} - placeholder="컬럼" - className="h-6 w-20 text-[10px]" - /> - - { - const newCats = [...(item.statConfig?.categories ?? [])]; - newCats[catIdx] = { - ...cat, - filter: { ...cat.filter, value: e.target.value }, - }; - onUpdate({ - ...item, - statConfig: { ...item.statConfig, categories: newCats }, - }); - }} - placeholder="값" - className="h-6 flex-1 text-[10px]" - /> -
-
- ))} - - {(item.statConfig?.categories ?? []).length === 0 && ( -

- 카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다 -

- )} -
-)} -``` - ---- - -### 파일 B: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx` - -**변경 유형**: 데이터 처리 로직 수정 2건 - -#### 변경 B-1: 차트 xAxisColumn 자동 설정 (라인 276~283 부근) - -차트에 groupBy가 있지만 xAxisColumn이 설정되지 않은 경우, 첫 번째 groupBy 컬럼을 자동으로 xAxisColumn에 반영. - -**현재 코드** (라인 276~283): -```tsx -case "chart": - return ( - - ); -``` - -**변경 코드**: -```tsx -case "chart": { - // groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정 - const chartItem = { ...item }; - if ( - item.dataSource.aggregation?.groupBy?.length && - !item.chartConfig?.xAxisColumn - ) { - chartItem.chartConfig = { - ...chartItem.chartConfig, - chartType: chartItem.chartConfig?.chartType ?? "bar", - xAxisColumn: item.dataSource.aggregation.groupBy[0], - }; - } - return ( - - ); -} -``` - -#### 변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297) - -**현재 코드** (버그): -```tsx -case "stat-card": { - const categoryData: Record = {}; - if (item.statConfig?.categories) { - for (const cat of item.statConfig.categories) { - categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값 - } - } - return ( - - ); +**현재 코드** (라인 367~372): +```typescript +export interface CardFieldBinding { + id: string; + columnName: string; + label: string; + textColor?: string; } ``` **변경 코드**: -```tsx -case "stat-card": { - const categoryData: Record = {}; - if (item.statConfig?.categories) { - for (const cat of item.statConfig.categories) { - if (cat.filter.column && cat.filter.value) { - // 카테고리 필터로 rows 필터링 - const filtered = itemData.rows.filter((row) => { - const cellValue = String(row[cat.filter.column] ?? ""); - const filterValue = String(cat.filter.value ?? ""); - switch (cat.filter.operator) { - case "=": - return cellValue === filterValue; - case "!=": - return cellValue !== filterValue; - case "like": - return cellValue.toLowerCase().includes(filterValue.toLowerCase()); - default: - return cellValue === filterValue; - } - }); - categoryData[cat.label] = filtered.length; - } else { - categoryData[cat.label] = itemData.rows.length; - } - } - } - return ( - - ); +```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") } ``` -**주의**: 이 방식은 rows에 전체 데이터가 있어야 작동함. 통계 카드의 dataSource에 집계 함수를 설정하지 않고(또는 groupBy를 사용하여) 원시 rows를 가져와야 한다. +**주의**: `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` + +#### 변경 B-1: 본문 필드 편집기(FieldEditor)에 값 유형 선택 추가 + +**현재**: 필드 편집 시 라벨, 컬럼, 텍스트색상만 설정 가능 + +**변경**: 값 유형 라디오("DB 컬럼" / "계산식") 추가 +- "DB 컬럼" 선택 시: 기존 컬럼 Select 표시 +- "계산식" 선택 시: 수식 입력란 + 사용 가능한 컬럼/변수 칩 목록 표시 +- 사용 가능한 변수: DB 컬럼명들 + `$input` (입력 필드 활성화 시) + +**하위 호환**: 기존 저장 데이터에 `valueType`이 없으면 `"column"`으로 기본 처리 + +#### 변경 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`이면 포장등록 버튼 숨김 --- @@ -406,20 +204,21 @@ case "stat-card": { | 순서 | 작업 | 파일 | 의존성 | 상태 | |------|------|------|--------|------| -| 1 | A-1: DataSourceEditor에 groupBy UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | -| 2 | A-2: 차트 xAxisColumn 입력 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | -| 3 | A-3: 통계 카드 카테고리 설정 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | -| 4 | B-1: 차트 xAxisColumn 자동 보정 로직 | PopDashboardComponent.tsx | 순서 1 | [x] | -| 5 | B-2: 통계 카드 카테고리별 필터 적용 | PopDashboardComponent.tsx | 순서 3 | [x] | -| 6 | 린트 검사 | 전체 | 순서 1~5 | [x] | -| 7 | C-1: SQL 빌더 방어 로직 (빈 컬럼/테이블 차단) | dataFetcher.ts | 없음 | [x] | -| 8 | C-2: refreshInterval 최소값 강제 (5초) | PopDashboardComponent.tsx | 없음 | [x] | -| 9 | 브라우저 테스트 | - | 순서 1~8 | [ ] | +| 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은 서로 독립이므로 병렬 가능. -순서 4는 순서 1의 groupBy 값이 있어야 의미 있음. -순서 5는 순서 3의 카테고리 설정이 있어야 의미 있음. -순서 7, 8은 백엔드 부하 방지를 위한 방어 패치. +순서 1, 2, 3은 독립이므로 병렬 가능. +순서 8은 독립이므로 병렬 가능. --- @@ -429,28 +228,21 @@ case "stat-card": { | 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 | |--------|------|-----------|-----------|-----------| -| `groupByOpen` | state (boolean) | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 | -| `setGroupByOpen` | state setter | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 | -| `chartItem` | const (DashboardItem) | PopDashboardComponent.tsx renderSingleItem 내부 | 동일 함수 내부 | 충돌 없음 | - -**Grep 검색 결과** (전체 pop-dashboard 폴더): -- `groupByOpen`: 0건 - 충돌 없음 -- `setGroupByOpen`: 0건 - 충돌 없음 -- `groupByColumns`: 0건 - 충돌 없음 -- `chartItem`: 0건 - 충돌 없음 -- `StatCategoryEditor`: 0건 - 충돌 없음 -- `loadCategoryData`: 0건 - 충돌 없음 +| `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 | 충돌 없음 | ### 기존 타입/함수 재사용 목록 | 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 | |------------|-----------|------------------------| -| `DataSourceConfig.aggregation.groupBy` | types.ts 라인 155 | A-1 UI에서 읽기/쓰기 | -| `ChartItemConfig.xAxisColumn` | types.ts 라인 248 | A-2 UI, B-1 자동 보정 | -| `StatCategory` | types.ts 라인 261 | A-3 카테고리 편집 | -| `StatCardConfig.categories` | types.ts 라인 268 | A-3 UI에서 읽기/쓰기 | -| `FilterOperator` | types.ts (import 이미 존재) | A-3 카테고리 필터 Select | -| `columns` (state) | PopDashboardConfig.tsx DataSourceEditor 내부 | A-1 groupBy 컬럼 목록 | +| `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: 계산식 모드에서 컬럼 칩 표시에 재사용 가능 | **사용처 있는데 정의 누락된 항목: 없음** @@ -458,61 +250,81 @@ case "stat-card": { ## 5. 에러 함정 경고 -### 함정 1: 차트에 groupBy만 설정하고 xAxisColumn을 비우면 -ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `{status: "수주", value: 79}` 형태. -`name` 키가 없으므로 X축이 빈 채로 렌더링됨. -**B-1의 자동 보정 로직이 필수**. 순서 4를 빠뜨리면 차트가 깨짐. +### 함정 1: 기존 저장 데이터 하위 호환 +기존에 저장된 `CardFieldBinding`에는 `valueType`이 없고 `columnName`이 필수였음. +**반드시** 런타임에서 `field.valueType || "column"` 폴백 처리해야 함. +Config UI에서도 `valueType` 미존재 시 `"column"` 기본값 적용 필요. -### 함정 2: 통계 카드에 집계 함수를 설정하면 -집계(COUNT 등)가 설정되면 rows에 `[{value: 87}]` 하나만 들어옴. -카테고리별 필터링이 작동하려면 **집계 함수를 "없음"으로 두거나, groupBy를 설정**해야 개별 행이 rows에 포함됨. -통계 카드에서는 **집계를 사용하지 않는 것이 올바른 사용법**. -설정 가이드 문서에 이 점을 명시해야 함. +### 함정 2: CardInputFieldConfig 하위 호환 +기존 `maxColumn`이 `limitColumn`으로 이름 변경됨. +기존 저장 데이터의 `maxColumn`을 `limitColumn`으로 읽어야 함. +런타임: `inputField?.limitColumn || (inputField as any)?.maxColumn` 폴백 필요. -### 함정 3: PopDashboardConfig.tsx의 import 누락 -현재 `FilterOperator`는 이미 import되어 있음 (라인 54). -`StatCategory`는 직접 사용하지 않고 `item.statConfig.categories` 구조로 접근하므로 import 불필요. -**새로운 import 추가 필요 없음.** +### 함정 3: evaluateFormula의 inputValue 전달 +FieldRow에 `inputValue`를 전달하려면, CardItem -> body.fields.map -> FieldRow 경로에서 `inputValue` prop을 추가해야 함. +입력 필드가 비활성화된 경우 `inputValue`는 0으로 전달. -### 함정 4: 통계 카드 카테고리 필터에서 숫자 비교 -`String(row[col])` vs `String(filter.value)` 비교이므로, 숫자 컬럼도 문자열로 비교됨. -`"100" === "100"`은 정상 동작하지만, `"100.00" !== "100"`이 될 수 있음. -현재 대부분 컬럼이 varchar이므로 문제없지만, numeric 컬럼 사용 시 주의. +### 함정 4: calculatedField 제거 시 기존 데이터 +기존 config에 `calculatedField` 데이터가 남아 있을 수 있음. +타입에서 제거하더라도 런타임 에러는 나지 않음 (unknown 속성은 무시됨). +다만 이전에 계산 필드로 설정한 내용은 사라짐 - 마이그레이션 없이 제거. -### 함정 5: DataSourceEditor의 columns state 타이밍 -`groupByOpen` Popover에서 `columns` 배열을 사용하는데, 테이블 선택 직후 columns가 아직 로딩 중일 수 있음. -기존 코드에서 `loadingCols` 상태로 버튼을 disabled 처리하고 있으므로 문제없음. +### 함정 5: columnName optional 변경 +`CardFieldBinding.columnName`이 optional이 됨. +기존에 `row[field.columnName]`으로 직접 접근하던 코드 전부 수정 필요. +`field.columnName ?? ""` 또는 valueType 분기 처리. --- ## 6. 검증 방법 -### 차트 (BUG-1, BUG-2) -1. 아이템 추가 > "차트" 선택 -2. 테이블: `sales_order_mng`, 집계: COUNT, 컬럼: `id`, 그룹핑: `status` -3. 차트 유형: 막대 차트 -4. 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1 +### 시나리오 1: 기존 본문 필드 (하위 호환) +1. 기존 저장된 카드리스트 열기 +2. 본문 필드에 기존 DB 컬럼 필드가 정상 표시되는지 확인 +3. 설정 패널에서 기존 필드가 "DB 컬럼" 유형으로 표시되는지 확인 -### 통계 카드 (BUG-3, BUG-4) -1. 아이템 추가 > "통계 카드" 선택 -2. 테이블: `sales_order_mng`, **집계: 없음** (중요!) -3. 카테고리 추가: - - "수주" / status / = / 수주 - - "진행중" / status / = / 진행중 - - "완료" / status / = / 완료 -4. 기대 결과: 수주 79, 진행중 7, 완료 1 +### 시나리오 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] 라인 185: overflow-hidden 제거 -- [x] 라인 266: overflow-auto 공통 적용 -- [x] 라인 275: 일반 모드 min-h-full 추가 -- [x] 린트 검사 통과 +- [x] overflow-hidden 제거 +- [x] overflow-auto 공통 적용 +- [x] 일반 모드 min-h-full 추가
@@ -521,28 +333,5 @@ ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 ` - [x] 뷰어 페이지에 레지스트리 초기화 import 추가 - [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체 -- [x] 린트 검사 통과 - - - -
-V2/V2 컴포넌트 설정 스키마 정비 (완료) - -- [x] 레거시 컴포넌트 스키마 제거 -- [x] V2 컴포넌트 overrides 스키마 정의 (16개) -- [x] V2 컴포넌트 overrides 스키마 정의 (9개) -- [x] componentConfig.ts 한 파일에서 통합 관리 - -
- -
-화면 복제 기능 개선 (진행 중) - -- [완료] DB 구조 개편 (menu_objid 의존성 제거) -- [완료] 복제 옵션 정리 -- [완료] 화면 간 연결 복제 버그 수정 -- [대기] 화면 간 연결 복제 테스트 -- [대기] 제어관리 복제 테스트 -- [대기] 추가 옵션 복제 테스트
diff --git a/STATUS.md b/STATUS.md index 3aa75278..09b8da12 100644 --- a/STATUS.md +++ b/STATUS.md @@ -17,9 +17,9 @@ | 순서 | 작업 | 상태 | |------|------|------| -| 1 | 브라우저 확인 (라벨 정렬 동작, 반응형 자동 크기, 미리보기 반영) | [ ] 대기 | -| 2 | 게이지/통계카드 테스트 시나리오 동작 확인 | [ ] 대기 | -| 3 | Phase 2 계획 수립 (pop-button, pop-icon) | [ ] 대기 | +| 1 | pop-card-list 입력 필드/계산 필드 구조 개편 (PLAN.MD 참고) | [ ] 코딩 대기 | +| 2 | pop-card-list 담기 버튼 독립화 (보류) | [ ] 대기 | +| 3 | pop-card-list 반응형 표시 런타임 적용 | [ ] 대기 | --- diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 8a5fa621..f605b513 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -515,3 +515,4 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { ); } + diff --git a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx index 806dedb1..209234ec 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useState, useEffect } from "react"; -import { Delete } from "lucide-react"; +import React, { useState, useEffect, useMemo } from "react"; +import { Delete, Trash2, Plus, ArrowLeft } from "lucide-react"; import { Dialog, DialogPortal, @@ -11,8 +11,14 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; import { PackageUnitModal, PACKAGE_UNITS, - type PackageUnit, } from "./PackageUnitModal"; +import type { CardPackageConfig, PackageEntry } from "../types"; + +type InputStep = + | "quantity" // 기본: 직접 수량 입력 (포장 OFF) + | "package_count" // 포장: 포장 수량 (N개) + | "quantity_per_unit" // 포장: 개당 수량 (M EA) + | "summary"; // 포장: 결과 확인 + 추가/완료 interface NumberInputModalProps { open: boolean; @@ -22,7 +28,10 @@ interface NumberInputModalProps { initialPackageUnit?: string; min?: number; maxValue?: number; - onConfirm: (value: number, packageUnit?: string) => void; + /** @deprecated packageConfig 사용 */ + showPackageUnit?: boolean; + packageConfig?: CardPackageConfig; + onConfirm: (value: number, packageUnit?: string, packageEntries?: PackageEntry[]) => void; } export function NumberInputModal({ @@ -33,51 +42,184 @@ export function NumberInputModal({ initialPackageUnit, min = 0, maxValue = 999999, + showPackageUnit, + packageConfig, onConfirm, }: NumberInputModalProps) { const [displayValue, setDisplayValue] = useState(""); - const [packageUnit, setPackageUnit] = useState(undefined); + const [step, setStep] = useState("quantity"); const [isPackageModalOpen, setIsPackageModalOpen] = useState(false); + // 포장 2단계 플로우용 상태 + const [selectedUnit, setSelectedUnit] = useState<{ id: string; label: string } | null>(null); + const [packageCount, setPackageCount] = useState(0); + const [entries, setEntries] = useState([]); + + const isPackageEnabled = packageConfig?.enabled ?? showPackageUnit ?? true; + const showSummary = packageConfig?.showSummaryMessage !== false; + + const entriesTotal = useMemo( + () => entries.reduce((sum, e) => sum + e.totalQuantity, 0), + [entries] + ); + const remainingQuantity = maxValue - entriesTotal; + useEffect(() => { if (open) { setDisplayValue(initialValue > 0 ? String(initialValue) : ""); - setPackageUnit(initialPackageUnit); + setStep("quantity"); + setSelectedUnit(null); + setPackageCount(0); + setEntries([]); } - }, [open, initialValue, initialPackageUnit]); + }, [open, initialValue]); + + // --- 키패드 핸들러 --- + + const currentMax = step === "quantity" + ? maxValue + : step === "package_count" + ? 9999 + : step === "quantity_per_unit" + ? remainingQuantity > 0 ? remainingQuantity : maxValue + : maxValue; const handleNumberClick = (num: string) => { const newStr = displayValue + num; const numericValue = parseInt(newStr, 10); - setDisplayValue(numericValue > maxValue ? String(maxValue) : newStr); + setDisplayValue(numericValue > currentMax ? String(currentMax) : newStr); }; const handleBackspace = () => setDisplayValue((prev) => prev.slice(0, -1)); const handleClear = () => setDisplayValue(""); - const handleMax = () => setDisplayValue(String(maxValue)); + const handleMax = () => setDisplayValue(String(currentMax)); + + // --- 확인 버튼: step에 따라 다르게 동작 --- const handleConfirm = () => { const numericValue = parseInt(displayValue, 10) || 0; - const finalValue = Math.max(min, Math.min(maxValue, numericValue)); - onConfirm(finalValue, packageUnit); + + if (step === "quantity") { + const finalValue = Math.max(min, Math.min(maxValue, numericValue)); + onConfirm(finalValue, undefined, undefined); + onOpenChange(false); + return; + } + + if (step === "package_count") { + if (numericValue <= 0) return; + setPackageCount(numericValue); + setDisplayValue(""); + setStep("quantity_per_unit"); + return; + } + + if (step === "quantity_per_unit") { + if (numericValue <= 0 || !selectedUnit) return; + const total = packageCount * numericValue; + const newEntry: PackageEntry = { + unitId: selectedUnit.id, + unitLabel: selectedUnit.label, + packageCount, + quantityPerUnit: numericValue, + totalQuantity: total, + }; + setEntries((prev) => [...prev, newEntry]); + setDisplayValue(""); + setStep("summary"); + return; + } + }; + + // --- 포장 단위 선택 콜백 --- + + const handlePackageUnitSelect = (unitId: string) => { + const matched = PACKAGE_UNITS.find((u) => u.value === unitId); + const matchedCustom = packageConfig?.customUnits?.find((cu) => cu.id === unitId); + const label = matched?.label ?? matchedCustom?.label ?? unitId; + + setSelectedUnit({ id: unitId, label }); + setDisplayValue(""); + setStep("package_count"); + }; + + // --- summary 액션 --- + + const handleAddMore = () => { + setIsPackageModalOpen(true); + }; + + const handleRemoveEntry = (index: number) => { + setEntries((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleComplete = () => { + if (entries.length === 0) return; + const total = entries.reduce((sum, e) => sum + e.totalQuantity, 0); + const lastUnit = entries[entries.length - 1].unitId; + onConfirm(total, lastUnit, entries); onOpenChange(false); }; - const handlePackageUnitSelect = (selected: PackageUnit) => { - setPackageUnit(selected); + const handleBack = () => { + if (step === "package_count") { + setStep("quantity"); + setSelectedUnit(null); + setDisplayValue(""); + } else if (step === "quantity_per_unit") { + setStep("package_count"); + setDisplayValue(String(packageCount)); + } else if (step === "summary") { + if (entries.length > 0) { + const last = entries[entries.length - 1]; + setEntries((prev) => prev.slice(0, -1)); + setSelectedUnit({ id: last.unitId, label: last.unitLabel }); + setPackageCount(last.packageCount); + setDisplayValue(String(last.quantityPerUnit)); + setStep("quantity_per_unit"); + } else { + setStep("quantity"); + setDisplayValue(""); + } + } }; - const matchedUnit = packageUnit - ? PACKAGE_UNITS.find((u) => u.value === packageUnit) - : null; - const packageUnitLabel = matchedUnit?.label ?? null; - const packageUnitEmoji = matchedUnit?.emoji ?? "📦"; + // --- 안내 메시지 --- + + const guideMessage = useMemo(() => { + switch (step) { + case "quantity": + return "수량을 입력하세요"; + case "package_count": + return `${selectedUnit?.label || "포장"}을(를) 몇 개 사용하시나요?`; + case "quantity_per_unit": + return `${selectedUnit?.label || "포장"} 1개에 몇 ${unit} 넣으시나요?`; + case "summary": + return ""; + default: + return ""; + } + }, [step, selectedUnit, unit]); + + // --- 헤더 정보 --- + + const headerLabel = useMemo(() => { + if (step === "summary") { + return `등록: ${entriesTotal.toLocaleString()} ${unit} / 남은: ${remainingQuantity.toLocaleString()} ${unit}`; + } + if (entries.length > 0) { + return `남은 ${remainingQuantity.toLocaleString()} ${unit}`; + } + return `최대 ${maxValue.toLocaleString()} ${unit}`; + }, [step, entriesTotal, remainingQuantity, maxValue, unit, entries.length]); const displayText = displayValue ? parseInt(displayValue, 10).toLocaleString() : ""; + const isBackVisible = step !== "quantity"; + return ( <> @@ -86,112 +228,199 @@ export function NumberInputModal({ - {/* 파란 헤더 */} + {/* 헤더 */}
- - 최대 {maxValue.toLocaleString()} {unit} - - +
+ {isBackVisible && ( + + )} + + {headerLabel} + +
+ {isPackageEnabled && step === "quantity" && ( + + )}
- {/* 숫자 표시 영역 */} -
- {displayText ? ( - - {displayText} - - ) : ( - 0 - )} -
+ {/* summary 단계: 포장 내역 리스트 */} + {step === "summary" ? ( +
+ {/* 안내 메시지 - 마지막 등록 결과 */} + {showSummary && entries.length > 0 && ( +
+ {(() => { + const last = entries[entries.length - 1]; + return `${last.packageCount}${last.unitLabel} x ${last.quantityPerUnit}${unit} = ${last.totalQuantity.toLocaleString()}${unit}`; + })()} +
+ )} - {/* 안내 텍스트 */} -

- 수량을 입력하세요 -

+ {/* 포장 내역 리스트 */} +
+

포장 내역

+ {entries.map((entry, idx) => ( +
+ + {entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}{unit} = {entry.totalQuantity.toLocaleString()}{unit} + + +
+ ))} +
- {/* 키패드 4x4 */} -
- {/* 1행: 7 8 9 ← (주황) */} - {["7", "8", "9"].map((n) => ( - - ))} - + {/* 합계 */} +
+ 합계 + + {entriesTotal.toLocaleString()} {unit} + +
- {/* 2행: 4 5 6 C (주황) */} - {["4", "5", "6"].map((n) => ( - - ))} - + {/* 남은 수량 */} + {remainingQuantity > 0 && ( +
+ 남은 수량 + + {remainingQuantity.toLocaleString()} {unit} + +
+ )} - {/* 3행: 1 2 3 MAX (파란) */} - {["1", "2", "3"].map((n) => ( - - ))} - + {/* 액션 버튼 */} +
+ {remainingQuantity > 0 && ( + + )} + +
+
+ ) : ( + <> + {/* 숫자 표시 영역 */} +
+ {displayText ? ( + + {displayText} + + ) : ( + 0 + )} +
- {/* 4행: 0 / 확인 (초록, 3칸) */} - - -
+ {/* 단계별 안내 텍스트 */} +

+ {guideMessage} +

+ + {/* 키패드 4x4 */} +
+ {["7", "8", "9"].map((n) => ( + + ))} + + + {["4", "5", "6"].map((n) => ( + + ))} + + + {["1", "2", "3"].map((n) => ( + + ))} + + + + +
+ + )}
@@ -201,8 +430,18 @@ export function NumberInputModal({ {/* 포장 단위 선택 모달 */} { + setIsPackageModalOpen(isOpen); + if (!isOpen && step === "summary") { + // summary에서 추가 포장 모달 닫힘 -> 단위 선택 안 한 경우 유지 + } + }} + onSelect={(unitId) => { + handlePackageUnitSelect(unitId); + setIsPackageModalOpen(false); + }} + enabledUnits={packageConfig?.enabledUnits} + customUnits={packageConfig?.customUnits} /> ); diff --git a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx index 0911bc47..ad050744 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx @@ -9,6 +9,7 @@ import { DialogOverlay, DialogClose, } from "@/components/ui/dialog"; +import type { CustomPackageUnit } from "../types"; export const PACKAGE_UNITS = [ { value: "box", label: "박스", emoji: "📦" }, @@ -24,19 +25,33 @@ export type PackageUnit = (typeof PACKAGE_UNITS)[number]["value"]; interface PackageUnitModalProps { open: boolean; onOpenChange: (open: boolean) => void; - onSelect: (unit: PackageUnit) => void; + onSelect: (unit: string) => void; + enabledUnits?: string[]; + customUnits?: CustomPackageUnit[]; } export function PackageUnitModal({ open, onOpenChange, onSelect, + enabledUnits, + customUnits, }: PackageUnitModalProps) { - const handleSelect = (unit: PackageUnit) => { - onSelect(unit); + const handleSelect = (unitValue: string) => { + onSelect(unitValue); onOpenChange(false); }; + // enabledUnits가 undefined면 전체 표시, 배열이면 필터링 + const filteredDefaults = enabledUnits + ? PACKAGE_UNITS.filter((u) => enabledUnits.includes(u.value)) + : [...PACKAGE_UNITS]; + + const allUnits = [ + ...filteredDefaults.map((u) => ({ value: u.value, label: u.label, emoji: u.emoji })), + ...(customUnits || []).map((cu) => ({ value: cu.id, label: cu.label, emoji: "📦" })), + ]; + return ( @@ -45,18 +60,16 @@ export function PackageUnitModal({ - {/* 헤더 */}

📦 포장 단위 선택

- {/* 3x2 그리드 */}
- {PACKAGE_UNITS.map((unit) => ( + {allUnits.map((unit) => (
- {/* X 닫기 버튼 */} + {allUnits.length === 0 && ( +
+ 사용 가능한 포장 단위가 없습니다 +
+ )} + Close diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index bf7d71ed..9d21a8d4 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -11,8 +11,11 @@ import React, { useEffect, useState, useRef, useMemo, useCallback } from "react"; import { useRouter } from "next/navigation"; -import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X } from "lucide-react"; -import * as LucideIcons from "lucide-react"; +import { + Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, + ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, + type LucideIcon, +} from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import type { @@ -20,10 +23,11 @@ import type { CardTemplateConfig, CardFieldBinding, CardInputFieldConfig, - CardCalculatedFieldConfig, CardCartActionConfig, + CardPackageConfig, CardPresetSpec, CartItem, + PackageEntry, } from "../types"; import { DEFAULT_CARD_IMAGE, @@ -33,11 +37,13 @@ import { dataApi } from "@/lib/api/data"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { NumberInputModal } from "./NumberInputModal"; -// Lucide 아이콘 동적 렌더링 +const LUCIDE_ICON_MAP: Record = { + ShoppingCart, Package, Truck, Box, Archive, Heart, Star, +}; + function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) { if (!name) return ; - const icons = LucideIcons as unknown as Record>; - const IconComp = icons[name]; + const IconComp = LUCIDE_ICON_MAP[name]; if (!IconComp) return ; return ; } @@ -157,25 +163,58 @@ export function PopCardListComponent({ const dataSource = config?.dataSource; const template = config?.cardTemplate; - // 이벤트 기반 company_code 필터링 - const [eventCompanyCode, setEventCompanyCode] = useState(); const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default"); const router = useRouter(); - useEffect(() => { - if (!screenId) return; - const unsub = subscribe("company_selected", (payload: unknown) => { - const p = payload as { companyCode?: string } | undefined; - setEventCompanyCode(p?.companyCode); - }); - return unsub; - }, [screenId, subscribe]); - // 데이터 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합) + const [externalFilters, setExternalFilters] = useState< + Map + >(new Map()); + + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__filter_condition`, + (payload: unknown) => { + const data = payload as { + value?: { fieldName?: string; value?: unknown }; + filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string }; + _connectionId?: string; + }; + const connId = data?._connectionId || "default"; + setExternalFilters(prev => { + const next = new Map(prev); + if (data?.value?.value) { + next.set(connId, { + fieldName: data.value.fieldName || "", + value: data.value.value, + filterConfig: data.filterConfig, + }); + } else { + next.delete(connId); + } + return next; + }); + } + ); + return unsub; + }, [componentId, subscribe]); + + // 카드 선택 시 selected_row 이벤트 발행 + const handleCardSelect = useCallback((row: RowData) => { + if (!componentId) return; + publish(`__comp_output__${componentId}__selected_row`, row); + }, [componentId, publish]); + // 확장/페이지네이션 상태 const [isExpanded, setIsExpanded] = useState(false); const [currentPage, setCurrentPage] = useState(1); @@ -202,7 +241,8 @@ export function PopCardListComponent({ const missingImageCountRef = useRef(0); const toastShownRef = useRef(false); - const spec: CardPresetSpec = CARD_PRESET_SPECS.large; + const cardSizeKey = config?.cardSize || "large"; + const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large; // 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열 const maxAllowedColumns = useMemo(() => { @@ -218,65 +258,80 @@ export function PopCardListComponent({ : maxGridColumns; const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns); - // 컨테이너 높이 기반 행 수 자동 계산 (잘림 방지) - const effectiveGridRows = useMemo(() => { - if (containerHeight <= 0) return configGridRows; + // 행 수: 설정값 그대로 사용 (containerHeight 연동 시 피드백 루프 위험) + const gridRows = configGridRows; - const controlBarHeight = 44; - const effectiveHeight = baseContainerHeight.current > 0 - ? baseContainerHeight.current - : containerHeight; - const availableHeight = effectiveHeight - controlBarHeight; - - const cardHeightWithGap = spec.height + spec.gap; - const fittableRows = Math.max(1, Math.floor( - (availableHeight + spec.gap) / cardHeightWithGap - )); - - return Math.min(configGridRows, fittableRows); - }, [containerHeight, configGridRows, spec]); - - const gridRows = effectiveGridRows; - - // 카드 크기: 컨테이너 실측 크기에서 gridColumns x gridRows 기준으로 동적 계산 + // 카드 크기: 높이는 프리셋 고정, 너비만 컨테이너 기반 동적 계산 + // (높이를 containerHeight에 연동하면 뷰어 모드의 minmax(auto) 그리드와 + // ResizeObserver 사이에서 피드백 루프가 발생해 무한 성장함) const scaled = useMemo((): ScaledConfig => { const gap = spec.gap; - const controlBarHeight = 44; - const buildScaledConfig = (cardWidth: number, cardHeight: number): ScaledConfig => { - const scale = cardHeight / spec.height; - return { - cardHeight, - cardWidth, - imageSize: Math.round(spec.imageSize * scale), - padding: Math.round(spec.padding * scale), - gap, - headerPaddingX: Math.round(spec.headerPadX * scale), - headerPaddingY: Math.round(spec.headerPadY * scale), - codeTextSize: Math.round(spec.codeText * scale), - titleTextSize: Math.round(spec.titleText * scale), - bodyTextSize: Math.round(spec.bodyText * scale), + const cardHeight = spec.height; + const minCardWidth = Math.round(spec.height * 1.6); + + const cardWidth = containerWidth > 0 + ? Math.max(minCardWidth, + Math.floor((containerWidth - gap * (gridColumns - 1)) / gridColumns)) + : minCardWidth; + + return { + cardHeight, + cardWidth, + imageSize: spec.imageSize, + padding: spec.padding, + gap, + headerPaddingX: spec.headerPadX, + headerPaddingY: spec.headerPadY, + codeTextSize: spec.codeText, + titleTextSize: spec.titleText, + bodyTextSize: spec.bodyText, + }; + }, [spec, containerWidth, gridColumns]); + + // 외부 필터 적용 (복수 필터 AND 결합) + const filteredRows = useMemo(() => { + if (externalFilters.size === 0) return rows; + + const matchSingleFilter = ( + row: RowData, + filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } } + ): boolean => { + const searchValue = String(filter.value).toLowerCase(); + if (!searchValue) return true; + + const fc = filter.filterConfig; + const columns: string[] = + fc?.targetColumns?.length + ? fc.targetColumns + : fc?.targetColumn + ? [fc.targetColumn] + : filter.fieldName + ? [filter.fieldName] + : []; + + if (columns.length === 0) return true; + + const mode = fc?.filterMode || "contains"; + + const matchCell = (cellValue: string) => { + switch (mode) { + case "equals": + return cellValue === searchValue; + case "starts_with": + return cellValue.startsWith(searchValue); + case "contains": + default: + return cellValue.includes(searchValue); + } }; + + return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase())); }; - if (containerWidth <= 0 || containerHeight <= 0) { - return buildScaledConfig(Math.round(spec.height * 1.6), spec.height); - } - - const effectiveHeight = baseContainerHeight.current > 0 - ? baseContainerHeight.current - : containerHeight; - - const availableHeight = effectiveHeight - controlBarHeight; - const availableWidth = containerWidth; - - const cardHeight = Math.max(spec.height, - Math.floor((availableHeight - gap * (gridRows - 1)) / gridRows)); - const cardWidth = Math.max(Math.round(spec.height * 1.6), - Math.floor((availableWidth - gap * (gridColumns - 1)) / gridColumns)); - - return buildScaledConfig(cardWidth, cardHeight); - }, [spec, containerWidth, containerHeight, gridColumns, gridRows]); + const allFilters = [...externalFilters.values()]; + return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f))); + }, [rows, externalFilters]); // 기본 상태에서 표시할 카드 수 const visibleCardCount = useMemo(() => { @@ -284,7 +339,7 @@ export function PopCardListComponent({ }, [gridColumns, gridRows]); // 더보기 버튼 표시 여부 - const hasMoreCards = rows.length > visibleCardCount; + const hasMoreCards = filteredRows.length > visibleCardCount; // 확장 상태에서 표시할 카드 수 계산 const expandedCardsPerPage = useMemo(() => { @@ -300,19 +355,17 @@ export function PopCardListComponent({ // 현재 표시할 카드 결정 const displayCards = useMemo(() => { if (!isExpanded) { - // 기본 상태: visibleCardCount만큼만 표시 - return rows.slice(0, visibleCardCount); + return filteredRows.slice(0, visibleCardCount); } else { - // 확장 상태: 현재 페이지의 카드들 (스크롤 가능하도록 더 많이) const start = (currentPage - 1) * expandedCardsPerPage; const end = start + expandedCardsPerPage; - return rows.slice(start, end); + return filteredRows.slice(start, end); } - }, [rows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); + }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); // 총 페이지 수 const totalPages = isExpanded - ? Math.ceil(rows.length / expandedCardsPerPage) + ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1; // 페이지네이션 필요: 확장 상태에서 전체 카드가 한 페이지를 초과할 때 const needsPagination = isExpanded && totalPages > 1; @@ -358,7 +411,12 @@ export function PopCardListComponent({ } }, [currentPage, isExpanded]); - // 데이터 조회 + // dataSource를 직렬화해서 의존성 안정화 (객체 참조 변경에 의한 불필요한 재호출 방지) + const dataSourceKey = useMemo( + () => JSON.stringify(dataSource || null), + [dataSource] + ); + useEffect(() => { if (!dataSource?.tableName) { setLoading(false); @@ -373,7 +431,6 @@ export function PopCardListComponent({ toastShownRef.current = false; try { - // 필터 조건 구성 const filters: Record = {}; if (dataSource.filters && dataSource.filters.length > 0) { dataSource.filters.forEach((f) => { @@ -383,28 +440,25 @@ export function PopCardListComponent({ }); } - // 이벤트로 수신한 company_code 필터 병합 - if (eventCompanyCode) { - filters["company_code"] = eventCompanyCode; - } + // 다중 정렬: 첫 번째 기준을 서버 정렬로 전달 (하위 호환: 단일 객체도 처리) + const sortArray = Array.isArray(dataSource.sort) + ? dataSource.sort + : dataSource.sort && typeof dataSource.sort === "object" + ? [dataSource.sort as { column: string; direction: "asc" | "desc" }] + : []; + const primarySort = sortArray.length > 0 ? sortArray[0] : undefined; + const sortBy = primarySort?.column; + const sortOrder = primarySort?.direction; - // 정렬 조건 - const sortBy = dataSource.sort?.column; - const sortOrder = dataSource.sort?.direction; - - // 개수 제한 const size = dataSource.limit?.mode === "limited" && dataSource.limit?.count ? dataSource.limit.count : 100; - // TODO: 조인 지원은 추후 구현 - // 현재는 단일 테이블 조회만 지원 - const result = await dataApi.getTableData(dataSource.tableName, { page: 1, size, - sortBy: sortOrder ? sortBy : undefined, + sortBy: sortBy || undefined, sortOrder, filters: Object.keys(filters).length > 0 ? filters : undefined, }); @@ -420,7 +474,7 @@ export function PopCardListComponent({ }; fetchData(); - }, [dataSource, eventCompanyCode]); + }, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps // 이미지 URL 없는 항목 체크 및 toast 표시 useEffect(() => { @@ -503,21 +557,27 @@ export function PopCardListComponent({ justifyContent: isHorizontalMode ? "start" : "center", }} > - {displayCards.map((row, index) => ( + {displayCards.map((row, index) => { + const rowKey = template?.header?.codeField && row[template.header.codeField] + ? String(row[template.header.codeField]) + : `card-${index}`; + return ( - ))} + ); + })} {/* 하단 컨트롤 영역 */} @@ -544,7 +604,7 @@ export function PopCardListComponent({ )} - {rows.length}건 + {filteredRows.length}건{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}건` : ""} @@ -589,69 +649,60 @@ function Card({ template, scaled, inputField, - calculatedField, + packageConfig, cartAction, publish, getSharedData, setSharedData, router, + onSelect, }: { row: RowData; template?: CardTemplateConfig; scaled: ScaledConfig; inputField?: CardInputFieldConfig; - calculatedField?: CardCalculatedFieldConfig; + packageConfig?: CardPackageConfig; cartAction?: CardCartActionConfig; publish: (eventName: string, payload?: unknown) => void; getSharedData: (key: string) => T | undefined; setSharedData: (key: string, value: unknown) => void; router: ReturnType; + onSelect?: (row: RowData) => void; }) { const header = template?.header; const image = template?.image; const body = template?.body; - // 입력 필드 상태 - const [inputValue, setInputValue] = useState( - inputField?.defaultValue || 0 - ); + const [inputValue, setInputValue] = useState(0); const [packageUnit, setPackageUnit] = useState(undefined); + const [packageEntries, setPackageEntries] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); - - // 담기/취소 토글 상태 const [isCarted, setIsCarted] = useState(false); - // 헤더 값 추출 const codeValue = header?.codeField ? row[header.codeField] : null; const titleValue = header?.titleField ? row[header.titleField] : null; - // 이미지 URL 결정 const imageUrl = image?.enabled && image?.imageColumn && row[image.imageColumn] ? String(row[image.imageColumn]) : image?.defaultImage || DEFAULT_CARD_IMAGE; - // 계산 필드 값 계산 - const calculatedValue = useMemo(() => { - if (!calculatedField?.enabled || !calculatedField?.formula) return null; - return evaluateFormula(calculatedField.formula, row, inputValue); - }, [calculatedField, row, inputValue]); - - // effectiveMax: DB 컬럼 우선, 없으면 inputField.max 폴백 + // limitColumn 우선, 하위 호환으로 maxColumn 폴백 + const limitCol = inputField?.limitColumn || inputField?.maxColumn; const effectiveMax = useMemo(() => { - if (inputField?.maxColumn) { - const colVal = Number(row[inputField.maxColumn]); + if (limitCol) { + const colVal = Number(row[limitCol]); if (!isNaN(colVal) && colVal > 0) return colVal; } - return inputField?.max ?? 999999; - }, [inputField, row]); + return 999999; + }, [limitCol, row]); - // 기본값이 설정되지 않은 경우 최대값으로 자동 초기화 + // 제한 컬럼이 있으면 최대값으로 자동 초기화 useEffect(() => { - if (inputField?.enabled && !inputField?.defaultValue && effectiveMax > 0 && effectiveMax < 999999) { + if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) { setInputValue(effectiveMax); } - }, [effectiveMax, inputField?.enabled, inputField?.defaultValue]); + }, [effectiveMax, inputField?.enabled, limitCol]); const cardStyle: React.CSSProperties = { height: `${scaled.cardHeight}px`, @@ -677,9 +728,10 @@ function Card({ setIsModalOpen(true); }; - const handleInputConfirm = (value: number, unit?: string) => { + const handleInputConfirm = (value: number, unit?: string, entries?: PackageEntry[]) => { setInputValue(value); setPackageUnit(unit); + setPackageEntries(entries || []); }; // 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글 @@ -688,6 +740,7 @@ function Card({ row, quantity: inputValue, packageUnit: packageUnit || undefined, + packageEntries: packageEntries.length > 0 ? packageEntries : undefined, }; const existing = getSharedData("cart_items") || []; @@ -721,10 +774,18 @@ function Card({ const cartLabel = cartAction?.label || "담기"; const cancelLabel = cartAction?.cancelLabel || "취소"; + const handleCardClick = () => { + onSelect?.(row); + }; + return (
{ if (e.key === "Enter" || e.key === " ") handleCardClick(); }} > {/* 헤더 영역 */} {(codeValue !== null || titleValue !== null) && ( @@ -777,7 +838,7 @@ function Card({
{body?.fields && body.fields.length > 0 ? ( body.fields.map((field) => ( - + )) ) : (
)} - - {/* 계산 필드 */} - {calculatedField?.enabled && calculatedValue !== null && ( -
- - {calculatedField.label || "계산값"} - - - {calculatedValue.toLocaleString()}{calculatedField.unit ? ` ${calculatedField.unit}` : ""} - -
- )}
- {/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (inputField 활성화 시만) */} - {inputField?.enabled && ( + {/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */} + {(inputField?.enabled || cartAction) && (
- {/* 수량 버튼 */} - + {/* 수량 버튼 (입력 필드 ON일 때만) */} + {inputField?.enabled && ( + + )} - {/* pop-icon 스타일 담기/취소 토글 버튼 */} - {isCarted ? ( - - ) : ( - ) : ( - + )} - - {cartLabel} - - + )}
)}
- {/* 숫자 입력 모달 */} {inputField?.enabled && ( )} @@ -883,14 +932,54 @@ function FieldRow({ field, row, scaled, + inputValue, }: { field: CardFieldBinding; row: RowData; scaled: ScaledConfig; + inputValue?: number; }) { - const value = row[field.columnName]; + const valueType = field.valueType || "column"; - // 비율 기반 라벨 최소 너비 + const displayValue = useMemo(() => { + if (valueType !== "formula") { + return formatValue(field.columnName ? row[field.columnName] : undefined); + } + + // 구조화된 수식 우선 + if (field.formulaLeft && field.formulaOperator) { + const rightVal = field.formulaRightType === "input" + ? (inputValue ?? 0) + : Number(row[field.formulaRight || ""] ?? 0); + const leftVal = Number(row[field.formulaLeft] ?? 0); + + let result: number | null = null; + switch (field.formulaOperator) { + case "+": result = leftVal + rightVal; break; + case "-": result = leftVal - rightVal; break; + case "*": result = leftVal * rightVal; break; + case "/": result = rightVal !== 0 ? leftVal / rightVal : null; break; + } + + if (result !== null && isFinite(result)) { + const formatted = Math.round(result * 100) / 100; + return field.unit ? `${formatted.toLocaleString()} ${field.unit}` : formatted.toLocaleString(); + } + return "-"; + } + + // 하위 호환: 레거시 formula 문자열 + if (field.formula) { + const result = evaluateFormula(field.formula, row, inputValue ?? 0); + if (result !== null) { + const formatted = result.toLocaleString(); + return field.unit ? `${formatted} ${field.unit}` : formatted; + } + } + return "-"; + }, [valueType, field, row, inputValue]); + + const isFormula = valueType === "formula"; const labelMinWidth = Math.round(50 * (scaled.bodyTextSize / 12)); return ( @@ -898,19 +987,17 @@ function FieldRow({ className="flex items-baseline" style={{ gap: `${Math.round(scaled.gap / 2)}px`, fontSize: `${scaled.bodyTextSize}px` }} > - {/* 라벨 */} {field.label} - {/* 값 - 기본 검정색, textColor 설정 시 해당 색상 */} - {formatValue(value)} + {displayValue} ); @@ -982,7 +1069,6 @@ function evaluateFormula( // 안전한 계산 (기본 산술 연산만 허용) // 허용: 숫자, +, -, *, /, (, ), 공백, 소수점 if (!/^[\d\s+\-*/().]+$/.test(expression)) { - console.warn("Invalid formula expression:", expression); return null; } @@ -995,7 +1081,7 @@ function evaluateFormula( return Math.round(result * 100) / 100; // 소수점 2자리까지 } catch (error) { - console.warn("Formula evaluation error:", error); + // 수식 평가 실패 시 null 반환 return null; } } diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index d8b31c34..418b4ddf 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -3,14 +3,13 @@ /** * pop-card-list 설정 패널 * - * 3개 탭: - * [테이블] - 데이터 테이블 선택 - * [카드 템플릿] - 헤더/이미지/본문 필드 + 레이아웃 설정 - * [데이터 소스] - 조인/필터/정렬/개수 설정 + * 2개 탭: + * [기본 설정] - 테이블 선택 + 조인/정렬 + 레이아웃 설정 + * [카드 템플릿] - 헤더/이미지/본문/입력/계산/담기 설정 */ import React, { useState, useEffect, useMemo } from "react"; -import { ChevronDown, ChevronRight, Plus, Trash2, Database } from "lucide-react"; +import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check, ChevronsUpDown } from "lucide-react"; import type { GridMode } from "@/components/pop/designer/types/pop-layout"; import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout"; import { Button } from "@/components/ui/button"; @@ -20,28 +19,40 @@ import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { cn } from "@/lib/utils"; import type { PopCardListConfig, CardListDataSource, + CardSortConfig, CardTemplateConfig, CardHeaderConfig, CardImageConfig, CardBodyConfig, CardFieldBinding, + FieldValueType, + FormulaOperator, + FormulaRightType, CardColumnJoin, CardColumnFilter, CardScrollDirection, FilterOperator, CardInputFieldConfig, - CardCalculatedFieldConfig, + CardPackageConfig, CardCartActionConfig, + CardResponsiveConfig, + ResponsiveDisplayMode, } from "../types"; import { CARD_SCROLL_DIRECTION_LABELS, + RESPONSIVE_DISPLAY_LABELS, DEFAULT_CARD_IMAGE, } from "../types"; import { @@ -51,6 +62,14 @@ import { type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; +// ===== 테이블별 그룹화된 컬럼 ===== + +interface ColumnGroup { + tableName: string; + displayName: string; + columns: ColumnInfo[]; +} + // ===== Props ===== interface ConfigPanelProps { @@ -96,7 +115,7 @@ const DEFAULT_CONFIG: PopCardListConfig = { cardSize: "large", }; -// ===== 색상 옵션 ===== +// ===== 색상 옵션 (본문 필드 텍스트 색상) ===== const COLOR_OPTIONS = [ { value: "__default__", label: "기본" }, @@ -112,25 +131,19 @@ const COLOR_OPTIONS = [ // ===== 메인 컴포넌트 ===== export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) { - // 3탭 구조: 기본 설정 (테이블+레이아웃) → 데이터 소스 → 카드 템플릿 - const [activeTab, setActiveTab] = useState<"basic" | "template" | "dataSource">( - "basic" - ); + const [activeTab, setActiveTab] = useState<"basic" | "template">("basic"); - // config가 없으면 기본값 사용 const cfg: PopCardListConfig = config || DEFAULT_CONFIG; - // config 업데이트 헬퍼 const updateConfig = (partial: Partial) => { onUpdate({ ...cfg, ...partial }); }; - // 테이블이 선택되었는지 확인 const hasTable = !!cfg.dataSource?.tableName; return (
- {/* 탭 헤더 - 3탭 구조 */} + {/* 탭 헤더 - 2탭 */}
- + + + + + + + 검색 결과가 없습니다. + + + {filtered.map((table) => ( + { + onSelect(table.tableName); + setOpen(false); + setSearch(""); + }} + className="text-xs" + > + +
+ {table.displayName || table.tableName} + {table.displayName && ( + + {table.tableName} + + )} +
+
+ ))} +
+
+
+
+ + ); +} + +// ===== 테이블별 그룹화된 컬럼 셀렉트 ===== + +function GroupedColumnSelect({ + columnGroups, + value, + onValueChange, + placeholder = "컬럼 선택", + allowNone = false, + noneLabel = "선택 안함", + className, +}: { + columnGroups: ColumnGroup[]; + value: string | undefined; + onValueChange: (value: string | undefined) => void; + placeholder?: string; + allowNone?: boolean; + noneLabel?: string; + className?: string; +}) { + return ( + + ); +} + // ===== 접기/펴기 섹션 컴포넌트 ===== function CollapsibleSection({ @@ -638,11 +840,11 @@ function CollapsibleSection({ function HeaderSettingsSection({ header, - columns, + columnGroups, onUpdate, }: { header: CardHeaderConfig; - columns: ColumnInfo[]; + columnGroups: ColumnGroup[]; onUpdate: (header: CardHeaderConfig) => void; }) { return ( @@ -650,24 +852,15 @@ function HeaderSettingsSection({ {/* 코드 필드 */}
- + onUpdate({ ...header, codeField: val })} + placeholder="컬럼 선택 (선택사항)" + allowNone + noneLabel="선택 안함" + className="mt-1" + />

카드 헤더 왼쪽에 표시될 코드 (예: ITEM032)

@@ -676,24 +869,15 @@ function HeaderSettingsSection({ {/* 제목 필드 */}
- + onUpdate({ ...header, titleField: val })} + placeholder="컬럼 선택 (선택사항)" + allowNone + noneLabel="선택 안함" + className="mt-1" + />

카드 헤더 오른쪽에 표시될 제목 (예: 너트 M10)

@@ -706,11 +890,11 @@ function HeaderSettingsSection({ function ImageSettingsSection({ image, - columns, + columnGroups, onUpdate, }: { image: CardImageConfig; - columns: ColumnInfo[]; + columnGroups: ColumnGroup[]; onUpdate: (image: CardImageConfig) => void; }) { return ( @@ -764,24 +948,15 @@ function ImageSettingsSection({ {/* 이미지 컬럼 선택 */}
- + onUpdate({ ...image, imageColumn: val })} + placeholder="컬럼 선택 (선택사항)" + allowNone + noneLabel="선택 안함 (기본 이미지 사용)" + className="mt-1" + />

DB에서 이미지 URL을 가져올 컬럼. URL이 없으면 기본 이미지 사용

@@ -796,22 +971,21 @@ function ImageSettingsSection({ function BodyFieldsSection({ body, - columns, + columnGroups, onUpdate, }: { body: CardBodyConfig; - columns: ColumnInfo[]; + columnGroups: ColumnGroup[]; onUpdate: (body: CardBodyConfig) => void; }) { const fields = body.fields || []; - // 필드 추가 const addField = () => { const newField: CardFieldBinding = { id: `field-${Date.now()}`, - columnName: "", label: "", - textColor: undefined, + valueType: "column", + columnName: "", }; onUpdate({ fields: [...fields, newField] }); }; @@ -858,7 +1032,7 @@ function BodyFieldsSection({ key={field.id} field={field} index={index} - columns={columns} + columnGroups={columnGroups} totalCount={fields.length} onUpdate={(updated) => updateField(index, updated)} onDelete={() => deleteField(index)} @@ -884,10 +1058,27 @@ function BodyFieldsSection({ // ===== 필드 편집기 ===== +const VALUE_TYPE_OPTIONS: { value: FieldValueType; label: string }[] = [ + { value: "column", label: "DB 컬럼" }, + { value: "formula", label: "계산식" }, +]; + +const FORMULA_OPERATOR_OPTIONS: { value: FormulaOperator; label: string }[] = [ + { value: "+", label: "+ (더하기)" }, + { value: "-", label: "- (빼기)" }, + { value: "*", label: "* (곱하기)" }, + { value: "/", label: "/ (나누기)" }, +]; + +const FORMULA_RIGHT_TYPE_OPTIONS: { value: FormulaRightType; label: string }[] = [ + { value: "column", label: "DB 컬럼" }, + { value: "input", label: "입력값" }, +]; + function FieldEditor({ field, index, - columns, + columnGroups, totalCount, onUpdate, onDelete, @@ -895,12 +1086,15 @@ function FieldEditor({ }: { field: CardFieldBinding; index: number; - columns: ColumnInfo[]; + columnGroups: ColumnGroup[]; totalCount: number; onUpdate: (field: CardFieldBinding) => void; onDelete: () => void; onMove: (direction: "up" | "down") => void; }) { + const valueType = field.valueType || "column"; + const rightType = field.formulaRightType || "input"; + return (
@@ -926,37 +1120,43 @@ function FieldEditor({ {/* 필드 설정 */}
+ {/* 1행: 라벨 + 값 유형 */}
- {/* 라벨 */}
onUpdate({ ...field, label: e.target.value })} - placeholder="예: 발주일" + placeholder="예: 미입고" className="mt-1 h-7 text-xs" />
- - {/* 컬럼 */} -
- +
+ + onUpdate({ ...field, formulaOperator: val === "__none__" ? undefined : val as FormulaOperator }) + } + > + + + + + 선택 + {FORMULA_OPERATOR_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + +
+ + {/* 오른쪽 값 유형 + 값 */} +
+ + + + {rightType === "column" && ( + onUpdate({ ...field, formulaRight: val || undefined })} + placeholder="컬럼 선택" + /> + )} + + {rightType === "input" && ( +

+ 카드의 숫자 입력 필드 값이 사용됩니다 +

+ )} +
+ + {/* 단위 */} +
+ + onUpdate({ ...field, unit: e.target.value })} + className="mt-1 h-7 text-xs" + placeholder="EA" + /> +
+ + {/* 수식 미리보기 */} + {field.formulaLeft && field.formulaOperator && ( +
+

수식 미리보기

+

+ {field.formulaLeft} {field.formulaOperator} {rightType === "input" ? "$input" : (field.formulaRight || "?")} +

+
+ )} + + )} + {/* 텍스트 색상 */}
@@ -1015,26 +1328,36 @@ function FieldEditor({ function InputFieldSettingsSection({ inputField, columns, + tables, onUpdate, }: { inputField?: CardInputFieldConfig; columns: ColumnInfo[]; + tables: TableInfo[]; onUpdate: (inputField: CardInputFieldConfig) => void; }) { const field = inputField || { enabled: false, - label: "발주 수량", unit: "EA", - defaultValue: 0, - min: 0, - max: 999999, - step: 1, }; + // 하위 호환: maxColumn -> limitColumn 마이그레이션 + const effectiveLimitColumn = field.limitColumn || field.maxColumn; + const updateField = (partial: Partial) => { onUpdate({ ...field, ...partial }); }; + // 저장 테이블 컬럼 로드 + const [saveTableColumns, setSaveTableColumns] = useState([]); + useEffect(() => { + if (field.saveTable) { + fetchTableColumns(field.saveTable).then(setSaveTableColumns); + } else { + setSaveTableColumns([]); + } + }, [field.saveTable]); + return (
{/* 활성화 스위치 */} @@ -1048,17 +1371,6 @@ function InputFieldSettingsSection({ {field.enabled && ( <> - {/* 라벨 */} -
- - updateField({ label: e.target.value })} - className="mt-1 h-7 text-xs" - placeholder="발주 수량" - /> -
- {/* 단위 */}
@@ -1070,86 +1382,22 @@ function InputFieldSettingsSection({ />
- {/* 기본값 */} -
- - updateField({ defaultValue: parseInt(e.target.value, 10) || 0 })} - className="mt-1 h-7 text-xs" - placeholder="0" - /> -
- - {/* 최소/최대값 */} -
-
- - updateField({ min: parseInt(e.target.value, 10) || 0 })} - className="mt-1 h-7 text-xs" - placeholder="0" - /> -
-
- - updateField({ max: parseInt(e.target.value, 10) || 999999 })} - className="mt-1 h-7 text-xs" - placeholder="999999" - /> -
-
- - {/* 최대값 컬럼 */} + {/* 제한 기준 컬럼 */}
-

- 설정 시 각 카드 행의 해당 컬럼 값이 숫자패드 최대값으로 사용됨 (예: unreceived_qty) -

-
- - {/* 저장 컬럼 (선택사항) */} -
- -

- 입력값을 저장할 DB 컬럼 (현재는 로컬 상태만 유지) + 각 카드 행의 해당 컬럼 값이 숫자패드 최대값 (예: order_qty)

+ + {/* 저장 대상 테이블 (검색 가능) */} +
+ + + updateField({ + saveTable: tableName || undefined, + saveColumn: undefined, + }) + } + /> +
+ + {/* 저장 대상 컬럼 */} + {field.saveTable && ( +
+ + +

+ 입력값이 저장될 DB 컬럼 +

+
+ )} + )}
); } -// ===== 계산 필드 설정 섹션 ===== +// ===== 포장등록 설정 섹션 ===== -function CalculatedFieldSettingsSection({ - calculatedField, - columns, +import { PACKAGE_UNITS } from "./PackageUnitModal"; + +function PackageSettingsSection({ + packageConfig, onUpdate, }: { - calculatedField?: CardCalculatedFieldConfig; - columns: ColumnInfo[]; - onUpdate: (calculatedField: CardCalculatedFieldConfig) => void; + packageConfig?: CardPackageConfig; + onUpdate: (config: CardPackageConfig) => void; }) { - const field = calculatedField || { + const config: CardPackageConfig = packageConfig || { enabled: false, - label: "미입고", - formula: "", - sourceColumns: [], - unit: "EA", }; - const updateField = (partial: Partial) => { - onUpdate({ ...field, ...partial }); + const updateConfig = (partial: Partial) => { + onUpdate({ ...config, ...partial }); + }; + + const enabledSet = new Set(config.enabledUnits ?? PACKAGE_UNITS.map((u) => u.value)); + + const toggleUnit = (value: string) => { + const next = new Set(enabledSet); + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + updateConfig({ enabledUnits: Array.from(next) }); + }; + + const addCustomUnit = () => { + const existing = config.customUnits || []; + const id = `custom_${Date.now()}`; + updateConfig({ + customUnits: [...existing, { id, label: "" }], + }); + }; + + const updateCustomUnit = (id: string, label: string) => { + const existing = config.customUnits || []; + updateConfig({ + customUnits: existing.map((cu) => (cu.id === id ? { ...cu, label } : cu)), + }); + }; + + const removeCustomUnit = (id: string) => { + const existing = config.customUnits || []; + updateConfig({ + customUnits: existing.filter((cu) => cu.id !== id), + }); }; return (
- {/* 활성화 스위치 */}
- + updateField({ enabled })} + checked={config.enabled} + onCheckedChange={(enabled) => updateConfig({ enabled })} />
- {field.enabled && ( + {config.enabled && ( <> - {/* 라벨 */} + {/* 기본 포장 단위 체크박스 */}
- - updateField({ label: e.target.value })} - className="mt-1 h-7 text-xs" - placeholder="미입고" - /> -
- - {/* 계산식 */} -
- - updateField({ formula: e.target.value })} - className="mt-1 h-7 text-xs font-mono" - placeholder="$input - received_qty" - /> -

- 사용 가능: 컬럼명, $input (입력값), +, -, *, / -

-
- - {/* 단위 */} -
- - updateField({ unit: e.target.value })} - className="mt-1 h-7 text-xs" - placeholder="EA" - /> -
- - {/* 사용 가능한 컬럼 목록 */} -
- -
-
- {columns.map((col) => ( - { - // 클릭 시 계산식에 컬럼명 추가 - const currentFormula = field.formula || ""; - updateField({ formula: currentFormula + col.name }); - }} - > - {col.name} - - ))} -
+ +
+ {PACKAGE_UNITS.map((unit) => ( + + ))}
-

- 클릭하면 계산식에 추가됩니다 -

+ + {/* 커스텀 단위 */} +
+ +
+ {(config.customUnits || []).map((cu) => ( +
+ updateCustomUnit(cu.id, e.target.value)} + className="h-7 flex-1 text-xs" + placeholder="단위 이름 (예: 파렛트)" + /> + +
+ ))} +
+ +
+ + {/* 계산 결과 안내 메시지 */} +
+ + updateConfig({ showSummaryMessage: checked })} + /> +
+

+ 포장 등록 시 계산 결과를 안내 메시지로 표시합니다 +

)}
); } -// ===== 조인 설정 섹션 ===== +// ===== 조인 설정 섹션 (테이블 선택 -> 컬럼 자동 매칭) ===== + +// 두 테이블 간 매칭 가능한 컬럼 쌍 찾기 +function findMatchingColumns( + sourceCols: ColumnInfo[], + targetCols: ColumnInfo[], +): Array<{ source: string; target: string; confidence: "high" | "medium" }> { + const matches: Array<{ source: string; target: string; confidence: "high" | "medium" }> = []; + const sourceNames = sourceCols.map((c) => c.name); + const targetNames = targetCols.map((c) => c.name); + + for (const src of sourceNames) { + for (const tgt of targetNames) { + // 정확히 같은 이름 (예: item_code = item_code) + if (src === tgt) { + matches.push({ source: src, target: tgt, confidence: "high" }); + continue; + } + // 소스가 _id/_code/_no 로 끝나고, 타겟 테이블에 같은 이름이 있는 경우 + // 예: source.customer_code -> target.customer_code (이미 위에서 처리됨) + // 소스 컬럼명이 타겟 컬럼명을 포함하거나, 타겟이 소스를 포함 + const suffixes = ["_id", "_code", "_no", "_number", "_key"]; + const srcBase = suffixes.reduce((name, s) => name.endsWith(s) ? name.slice(0, -s.length) : name, src); + const tgtBase = suffixes.reduce((name, s) => name.endsWith(s) ? name.slice(0, -s.length) : name, tgt); + if (srcBase && tgtBase && srcBase === tgtBase && src !== tgt) { + matches.push({ source: src, target: tgt, confidence: "medium" }); + } + } + } + + // confidence 높은 순 정렬 + return matches.sort((a, b) => (a.confidence === "high" ? -1 : 1) - (b.confidence === "high" ? -1 : 1)); +} function JoinSettingsSection({ dataSource, @@ -1284,175 +1647,185 @@ function JoinSettingsSection({ }) { const joins = dataSource.joins || []; const [sourceColumns, setSourceColumns] = useState([]); - const [targetColumnsMap, setTargetColumnsMap] = useState< - Record - >({}); + const [targetColumnsMap, setTargetColumnsMap] = useState>({}); - // 소스 테이블 컬럼 로드 useEffect(() => { if (dataSource.tableName) { fetchTableColumns(dataSource.tableName).then(setSourceColumns); + } else { + setSourceColumns([]); } }, [dataSource.tableName]); - // 조인 추가 - const addJoin = () => { - const newJoin: CardColumnJoin = { - targetTable: "", - joinType: "LEFT", - sourceColumn: "", - targetColumn: "", - }; - onUpdate({ joins: [...joins, newJoin] }); + const getTableLabel = (tableName: string) => { + const found = tables.find((t) => t.tableName === tableName); + return found?.displayName || tableName; }; - // 조인 업데이트 - const updateJoin = (index: number, updated: CardColumnJoin) => { - const newJoins = [...joins]; - newJoins[index] = updated; - onUpdate({ joins: newJoins }); + // 대상 테이블 컬럼 로드 + 자동 매칭 + const loadTargetAndAutoMatch = (index: number, join: CardColumnJoin, targetTable: string) => { + const updated = { ...join, targetTable, sourceColumn: "", targetColumn: "" }; - // 대상 테이블 컬럼 로드 - if (updated.targetTable && !targetColumnsMap[updated.targetTable]) { - fetchTableColumns(updated.targetTable).then((cols) => { - setTargetColumnsMap((prev) => ({ - ...prev, - [updated.targetTable]: cols, - })); + const doMatch = (targetCols: ColumnInfo[]) => { + const matches = findMatchingColumns(sourceColumns, targetCols); + if (matches.length > 0) { + updated.sourceColumn = matches[0].source; + updated.targetColumn = matches[0].target; + } + const newJoins = [...joins]; + newJoins[index] = updated; + onUpdate({ joins: newJoins }); + }; + + if (targetColumnsMap[targetTable]) { + doMatch(targetColumnsMap[targetTable]); + } else { + fetchTableColumns(targetTable).then((cols) => { + setTargetColumnsMap((prev) => ({ ...prev, [targetTable]: cols })); + doMatch(cols); }); } }; - // 조인 삭제 + const updateJoin = (index: number, updated: CardColumnJoin) => { + const newJoins = [...joins]; + newJoins[index] = updated; + onUpdate({ joins: newJoins }); + }; + const deleteJoin = (index: number) => { const newJoins = joins.filter((_, i) => i !== index); onUpdate({ joins: newJoins.length > 0 ? newJoins : undefined }); }; + const addJoin = () => { + onUpdate({ joins: [...joins, { targetTable: "", joinType: "LEFT", sourceColumn: "", targetColumn: "" }] }); + }; + return (
{joins.length === 0 ? (

- 다른 테이블과 조인하여 추가 컬럼을 사용할 수 있습니다 + 다른 테이블을 연결하면 추가 정보를 카드에 표시할 수 있습니다

) : (
- {joins.map((join, index) => ( -
-
- - 조인 {index + 1} - - -
+ {joins.map((join, index) => { + const targetCols = targetColumnsMap[join.targetTable] || []; + const matchingPairs = join.targetTable + ? findMatchingColumns(sourceColumns, targetCols) + : []; + const hasAutoMatch = join.sourceColumn && join.targetColumn; - {/* 조인 타입 */} - - - {/* 대상 테이블 */} - - - {/* ON 조건 */} - {join.targetTable && ( -
- - = - + return ( +
+
+ + {join.targetTable ? getTableLabel(join.targetTable) : "테이블 선택"} + +
- )} -
- ))} + + {/* 대상 테이블 선택 (검색 가능) */} + t.tableName !== dataSource.tableName)} + value={join.targetTable || ""} + onSelect={(val) => loadTargetAndAutoMatch(index, join, val)} + /> + + {/* 자동 매칭 결과 또는 수동 선택 */} + {join.targetTable && ( +
+ {/* 자동 매칭 성공: 결과 표시 */} + {hasAutoMatch ? ( +
+ + + 자동 연결됨: {join.sourceColumn} = {join.targetColumn} + +
+ ) : ( +
+ + 자동 매칭되는 컬럼이 없습니다. 직접 선택하세요. + +
+ )} + + {/* 다른 매칭 후보가 있으면 표시 */} + {matchingPairs.length > 1 && ( +
+ 다른 연결 기준: + {matchingPairs.map((pair) => { + const isActive = join.sourceColumn === pair.source && join.targetColumn === pair.target; + if (isActive) return null; + return ( + + ); + })} +
+ )} + + {/* 수동 선택 (펼치기) */} +
+ + 직접 컬럼 선택 + +
+ + = + +
+
+
+ )} +
+ ); + })}
)} -
); @@ -1593,101 +1966,108 @@ function FilterSettingsSection({ ); } -// ===== 정렬 설정 섹션 ===== +// ===== 정렬 설정 섹션 (다중 정렬) ===== function SortSettingsSection({ dataSource, - columns, + columnGroups, onUpdate, }: { dataSource: CardListDataSource; - columns: ColumnInfo[]; + columnGroups: ColumnGroup[]; onUpdate: (partial: Partial) => void; }) { - const sort = dataSource.sort; + // 하위 호환: 이전 형식(단일 객체)이 저장되어 있을 수 있음 + const sorts: CardSortConfig[] = Array.isArray(dataSource.sort) + ? dataSource.sort + : dataSource.sort && typeof dataSource.sort === "object" + ? [dataSource.sort as CardSortConfig] + : []; + + const addSort = () => { + onUpdate({ sort: [...sorts, { column: "", direction: "desc" }] }); + }; + + const updateSort = (index: number, updated: CardSortConfig) => { + const newSorts = [...sorts]; + newSorts[index] = updated; + onUpdate({ sort: newSorts }); + }; + + const deleteSort = (index: number) => { + const newSorts = sorts.filter((_, i) => i !== index); + onUpdate({ sort: newSorts.length > 0 ? newSorts : undefined }); + }; return (
- {/* 정렬 사용 여부 */} -
- - -
+

+ 화면 로드 시 적용되는 기본 정렬 순서입니다. 위에 있는 항목이 우선 적용됩니다. +

- {sort && ( + {sorts.length === 0 ? ( +
+

정렬 기준이 없습니다

+
+ ) : (
- {/* 정렬 컬럼 */} -
- - -
- - {/* 정렬 방향 */} -
- -
+ {sorts.map((sort, index) => ( +
+ + {index + 1} + +
+ updateSort(index, { ...sort, column: val || "" })} + placeholder="컬럼 선택" + /> +
+
-
+ ))}
)} + +
); } @@ -1872,3 +2252,142 @@ function CartActionSettingsSection({
); } + +// ===== 반응형 표시 설정 섹션 ===== + +const RESPONSIVE_MODES: ResponsiveDisplayMode[] = ["required", "shrink", "hidden"]; + +function ResponsiveDisplaySection({ + config, + onUpdate, +}: { + config: PopCardListConfig; + onUpdate: (partial: Partial) => void; +}) { + const template = config.cardTemplate || { header: {}, image: { enabled: false }, body: { fields: [] } }; + const responsive = config.responsiveDisplay || {}; + const bodyFields = template.body?.fields || []; + + const hasHeader = !!template.header?.codeField || !!template.header?.titleField; + const hasImage = !!template.image?.enabled; + const hasFields = bodyFields.length > 0; + + const updateResponsive = (partial: Partial) => { + onUpdate({ responsiveDisplay: { ...responsive, ...partial } }); + }; + + const updateFieldMode = (fieldId: string, mode: ResponsiveDisplayMode) => { + updateResponsive({ + fields: { ...(responsive.fields || {}), [fieldId]: mode }, + }); + }; + + if (!hasHeader && !hasImage && !hasFields) { + return ( +
+

+ 카드 템플릿에 항목을 먼저 추가하세요 +

+
+ ); + } + + return ( +
+

+ 화면이 좁아질 때 각 항목이 어떻게 표시될지 설정합니다. +

+ +
+ {RESPONSIVE_MODES.map((mode) => ( + + {RESPONSIVE_DISPLAY_LABELS[mode]} + {mode === "required" && " = 항상 표시"} + {mode === "shrink" && " = 축소 가능"} + {mode === "hidden" && " = 숨김 가능"} + + ))} +
+ +
+ {template.header?.codeField && ( + updateResponsive({ code: mode })} + /> + )} + {template.header?.titleField && ( + updateResponsive({ title: mode })} + /> + )} + {hasImage && ( + updateResponsive({ image: mode })} + /> + )} + {bodyFields.map((field) => ( + updateFieldMode(field.id, mode)} + /> + ))} +
+
+ ); +} + +function ResponsiveDisplayRow({ + label, + sublabel, + value, + onChange, +}: { + label: string; + sublabel?: string; + value: ResponsiveDisplayMode; + onChange: (mode: ResponsiveDisplayMode) => void; +}) { + return ( +
+
+ {label} + {sublabel && ( + + {sublabel} + + )} +
+
+ {RESPONSIVE_MODES.map((mode) => ( + + ))} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx index 6e417c1e..aea93555 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -60,6 +60,15 @@ PopComponentRegistry.registerComponent({ configPanel: PopCardListConfigPanel, preview: PopCardListPreviewComponent, defaultProps: defaultConfig, + connectionMeta: { + sendable: [ + { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" }, + { key: "cart_item_added", label: "담기 완료", type: "event", description: "카드 담기 시 해당 행 + 수량 데이터 전달" }, + ], + receivable: [ + { key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, + ], + }, touchOptimized: true, supportedDevices: ["mobile", "tablet"], }); diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 632ae01a..a9a0a83a 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -364,11 +364,24 @@ export interface CardColumnFilter { // ----- 본문 필드 바인딩 (라벨-값 쌍) ----- +export type FieldValueType = "column" | "formula"; +export type FormulaOperator = "+" | "-" | "*" | "/"; +export type FormulaRightType = "column" | "input"; + export interface CardFieldBinding { id: string; - columnName: string; // DB 컬럼명 - label: string; // 표시 라벨 (예: "발주일") - textColor?: string; // 텍스트 색상 (예: "#ef4444" 빨간색) + label: string; + valueType: FieldValueType; + columnName?: string; // valueType === "column"일 때 DB 컬럼명 + // 구조화된 수식 (클릭형 빌더) + formulaLeft?: string; // 왼쪽: DB 컬럼명 + formulaOperator?: FormulaOperator; + formulaRightType?: FormulaRightType; // "column" 또는 "input"($input) + formulaRight?: string; // rightType === "column"일 때 DB 컬럼명 + /** @deprecated 구조화 수식 필드 사용, 하위 호환용 */ + formula?: string; + unit?: string; + textColor?: string; } // ----- 카드 헤더 설정 (코드 + 제목) ----- @@ -406,11 +419,16 @@ export interface CardTemplateConfig { // ----- 데이터 소스 (테이블 단위) ----- +export interface CardSortConfig { + column: string; + direction: "asc" | "desc"; +} + export interface CardListDataSource { tableName: string; joins?: CardColumnJoin[]; filters?: CardColumnFilter[]; - sort?: { column: string; direction: "asc" | "desc" }; + sort?: CardSortConfig[]; limit?: { mode: "all" | "limited"; count?: number }; } @@ -437,25 +455,40 @@ export const CARD_SCROLL_DIRECTION_LABELS: Record = export interface CardInputFieldConfig { enabled: boolean; - columnName?: string; // 입력값이 저장될 컬럼 - label?: string; // 표시 라벨 (예: "발주 수량") - unit?: string; // 단위 (예: "EA", "개") - defaultValue?: number; // 기본값 - min?: number; // 최소값 - max?: number; // 최대값 - maxColumn?: string; // 최대값을 DB 컬럼에서 동적으로 가져올 컬럼명 (설정 시 row[maxColumn] 우선) - step?: number; // 증감 단위 + unit?: string; // 단위 (예: "EA") + limitColumn?: string; // 제한 기준 컬럼 (해당 행의 이 컬럼 값이 최대값) + saveTable?: string; // 저장 대상 테이블 + saveColumn?: string; // 저장 대상 컬럼 + /** @deprecated limitColumn 사용 */ + maxColumn?: string; + /** @deprecated 미사용, 하위 호환용 */ + label?: string; + /** @deprecated packageConfig로 이동, 하위 호환용 */ + showPackageUnit?: boolean; } -// ----- 카드 내 계산 필드 설정 ----- +// ----- 포장등록 설정 ----- -export interface CardCalculatedFieldConfig { - enabled: boolean; - label?: string; // 표시 라벨 (예: "미입고") - formula: string; // 계산식 (예: "order_qty - inbound_qty") - sourceColumns: string[]; // 계산에 사용되는 컬럼들 - resultColumn?: string; // 결과를 저장할 컬럼 (선택) - unit?: string; // 단위 (예: "EA") +export interface CustomPackageUnit { + id: string; + label: string; // 표시명 (예: "파렛트") +} + +export interface CardPackageConfig { + enabled: boolean; // 포장등록 기능 ON/OFF + enabledUnits?: string[]; // 활성화된 기본 단위 (예: ["box", "bag"]), undefined면 전체 표시 + customUnits?: CustomPackageUnit[]; // 디자이너가 추가한 커스텀 단위 + showSummaryMessage?: boolean; // 계산 결과 안내 메시지 표시 (기본 true) +} + +// ----- 포장 내역 (2단계 계산 결과) ----- + +export interface PackageEntry { + unitId: string; // 포장 단위 ID (예: "box") + unitLabel: string; // 포장 단위 표시명 (예: "박스") + packageCount: number; // 포장 수량 (예: 3) + quantityPerUnit: number; // 개당 수량 (예: 80) + totalQuantity: number; // 합계 = packageCount * quantityPerUnit } // ----- 담기 버튼 데이터 구조 (추후 API 연동 시 이 shape 그대로 사용) ----- @@ -464,6 +497,7 @@ export interface CartItem { row: Record; // 카드 원본 행 데이터 quantity: number; // 입력 수량 packageUnit?: string; // 포장 단위 (box/bag/pack/bundle/roll/barrel) + packageEntries?: PackageEntry[]; // 포장 내역 (2단계 계산 시) } // ----- 담기 버튼 액션 설정 (pop-icon 스타일 + 장바구니 연동) ----- @@ -533,31 +567,39 @@ export const CARD_PRESET_SPECS: Record = { }, }; +// ----- 반응형 표시 모드 ----- + +export type ResponsiveDisplayMode = "required" | "shrink" | "hidden"; + +export const RESPONSIVE_DISPLAY_LABELS: Record = { + required: "필수", + shrink: "축소", + hidden: "숨김", +}; + +export interface CardResponsiveConfig { + code?: ResponsiveDisplayMode; + title?: ResponsiveDisplayMode; + image?: ResponsiveDisplayMode; + fields?: Record; +} + +// ----- pop-card-list 전체 설정 ----- + export interface PopCardListConfig { - // 데이터 소스 (테이블 단위) dataSource: CardListDataSource; - - // 카드 템플릿 (헤더 + 이미지 + 본문) cardTemplate: CardTemplateConfig; - - // 스크롤 방향 scrollDirection: CardScrollDirection; cardsPerRow?: number; // deprecated, gridColumns 사용 - cardSize: CardSize; // 프리셋 크기 (small/medium/large) + cardSize: CardSize; - // 그리드 배치 설정 (가로 x 세로) - gridColumns?: number; // 가로 카드 수 (기본값: 3) - gridRows?: number; // 세로 카드 수 (기본값: 2) + gridColumns?: number; + gridRows?: number; - // 확장 설정 (더보기 클릭 시 그리드 rowSpan 변경) - // expandedRowSpan 제거됨 - 항상 원래 크기의 2배로 확장 + // 반응형 표시 설정 + responsiveDisplay?: CardResponsiveConfig; - // 입력 필드 설정 (수량 입력 등) inputField?: CardInputFieldConfig; - - // 계산 필드 설정 (미입고 등 자동 계산) - calculatedField?: CardCalculatedFieldConfig; - - // 담기 버튼 액션 설정 (pop-icon 스타일) + packageConfig?: CardPackageConfig; cartAction?: CardCartActionConfig; } From 0ca031282be1a7d8d35b8832a8857f9c337b3ee1 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 26 Feb 2026 16:00:07 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat(pop-cart):=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B5=AC=ED=98=84=20+=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=A0=81=20=EC=BB=AC=EB=9F=BC=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 장바구니 담기 -> DB 저장 전체 플로우 구현 및 검증 완료. - useCartSync 훅 신규: DB(cart_items) <-> 로컬 상태 동기화, dirty check, 일괄 저장 - pop-button cart 프리셋: 배지 표시, 저장 트리거, 확인 모달, 3색 데이터 흐름 시각화 - pop-card-list: 담기/취소 UI, cart_save_trigger 수신 시 saveToDb 실행 - 선택적 컬럼 저장: RowDataMode(all/selected) + 연결 기반 자동 컬럼 로딩 - ComponentEditorPanel: allComponents/connections/componentId를 ConfigPanel에 전달 - connectionMeta: cart_save_trigger/cart_updated/cart_save_completed 이벤트 정의 - ConnectionEditor: 이벤트 타입 연결 구분 (데이터 vs 이벤트) - types.ts: CartItemWithId, CartSyncStatus, CartButtonConfig 등 타입 추가 - 접근성: NumberInputModal/PackageUnitModal에 DialogTitle 추가 Made-with: Cursor --- docker/dev/docker-compose.frontend.mac.yml | 1 + .../designer/panels/ComponentEditorPanel.tsx | 9 +- .../pop/designer/panels/ConnectionEditor.tsx | 38 +- frontend/hooks/pop/index.ts | 4 + frontend/hooks/pop/useCartSync.ts | 338 ++++++++++ .../registry/pop-components/pop-button.tsx | 626 ++++++++++++++++-- .../pop-card-list/NumberInputModal.tsx | 9 +- .../pop-card-list/PackageUnitModal.tsx | 5 +- .../pop-card-list/PopCardListComponent.tsx | 147 ++-- .../pop-card-list/PopCardListConfig.tsx | 135 ++-- .../pop-components/pop-card-list/index.tsx | 8 +- .../pop-search/PopSearchConfig.tsx | 89 ++- frontend/lib/registry/pop-components/types.ts | 38 +- frontend/next.config.mjs | 4 +- 14 files changed, 1238 insertions(+), 213 deletions(-) create mode 100644 frontend/hooks/pop/useCartSync.ts 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/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index f605b513..12c21e4f 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -213,6 +213,8 @@ export default function ComponentEditorPanel({ previewPageIndex={previewPageIndex} onPreviewPage={onPreviewPage} modals={modals} + allComponents={allComponents} + connections={connections} /> @@ -404,9 +406,11 @@ interface ComponentSettingsFormProps { previewPageIndex?: number; onPreviewPage?: (pageIndex: number) => void; modals?: PopModalDefinition[]; + allComponents?: PopComponentDefinitionV5[]; + connections?: PopDataConnection[]; } -function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) { +function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals, allComponents, connections }: ComponentSettingsFormProps) { // PopComponentRegistry에서 configPanel 가져오기 const registeredComp = PopComponentRegistry.getComponent(component.type); const ConfigPanel = registeredComp?.configPanel; @@ -440,6 +444,9 @@ function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIn onPreviewPage={onPreviewPage} previewPageIndex={previewPageIndex} modals={modals} + allComponents={allComponents} + connections={connections} + componentId={component.id} /> ) : (
diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx index 2e92d602..725b4f3f 100644 --- a/frontend/components/pop/designer/panels/ConnectionEditor.tsx +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -272,6 +272,25 @@ function ConnectionForm({ ? 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), @@ -322,6 +341,8 @@ function ConnectionForm({ const handleSubmit = () => { if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return; + const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput); + onSubmit({ sourceComponent: component.id, sourceField: "", @@ -330,7 +351,7 @@ function ConnectionForm({ targetField: "", targetInput: selectedTargetInput, filterConfig: - filterColumns.length > 0 + !isEvent && filterColumns.length > 0 ? { targetColumn: filterColumns[0], targetColumns: filterColumns, @@ -427,8 +448,8 @@ function ConnectionForm({
)} - {/* 필터 설정 */} - {selectedTargetInput && ( + {/* 필터 설정: event 타입 연결이면 숨김 */} + {selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (

필터할 컬럼

@@ -607,6 +628,17 @@ function ReceiveSection({ // 유틸 // ======================================== +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, diff --git a/frontend/hooks/pop/index.ts b/frontend/hooks/pop/index.ts index 3a6c792b..7ae7e953 100644 --- a/frontend/hooks/pop/index.ts +++ b/frontend/hooks/pop/index.ts @@ -22,5 +22,9 @@ 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/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/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index b5532c30..a9ea0ece 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import React, { useCallback, useState, useEffect, useMemo } from "react"; import { cn } from "@/lib/utils"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -24,8 +24,10 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { DataFlowAPI } from "@/lib/api/dataflow"; import { usePopAction } from "@/hooks/pop/usePopAction"; import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext"; +import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { Save, Trash2, @@ -44,6 +46,8 @@ import { Copy, Settings, ChevronDown, + ShoppingCart, + ShoppingBag, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -113,18 +117,30 @@ export type ButtonPreset = | "logout" | "menu" | "modal-open" + | "cart" | "custom"; +/** row_data 저장 모드 */ +export type RowDataMode = "all" | "selected"; + +/** 장바구니 버튼 전용 설정 */ +export interface CartButtonConfig { + cartScreenId?: string; + rowDataMode?: RowDataMode; + selectedColumns?: string[]; +} + /** pop-button 전체 설정 */ export interface PopButtonConfig { label: string; variant: ButtonVariant; - icon?: string; // Lucide 아이콘 이름 + icon?: string; iconOnly?: boolean; preset: ButtonPreset; confirm?: ConfirmConfig; action: ButtonMainAction; followUpActions?: FollowUpAction[]; + cart?: CartButtonConfig; } // ======================================== @@ -163,6 +179,7 @@ const PRESET_LABELS: Record = { logout: "로그아웃", menu: "메뉴 (드롭다운)", "modal-open": "모달 열기", + cart: "장바구니 저장", custom: "직접 설정", }; @@ -201,6 +218,8 @@ const ICON_OPTIONS: { value: string; label: string }[] = [ { value: "Copy", label: "복사 (Copy)" }, { value: "Settings", label: "설정 (Settings)" }, { value: "ChevronDown", label: "아래 화살표 (ChevronDown)" }, + { value: "ShoppingCart", label: "장바구니 (ShoppingCart)" }, + { value: "ShoppingBag", label: "장바구니 담김 (ShoppingBag)" }, ]; /** 프리셋별 기본 설정 */ @@ -244,6 +263,13 @@ const PRESET_DEFAULTS: Record> = { confirm: { enabled: false }, action: { type: "modal", modalMode: "fullscreen" }, }, + cart: { + label: "장바구니 저장", + variant: "default", + icon: "ShoppingCart", + confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" }, + action: { type: "event" }, + }, custom: { label: "버튼", variant: "default", @@ -279,10 +305,42 @@ function SectionDivider({ label }: { label: string }) { ); } +/** 장바구니 데이터 매핑 행 (읽기 전용) */ +function CartMappingRow({ + source, + target, + desc, + auto, +}: { + source: string; + target: string; + desc?: string; + auto?: boolean; +}) { + return ( +
+ + {source} + + +
+ + {target} + + {desc && ( +

{desc}

+ )} +
+
+ ); +} + /** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */ const LUCIDE_ICON_MAP: Record = { Save, Trash2, LogOut, Menu, ExternalLink, Plus, Check, X, Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown, + ShoppingCart, + ShoppingBag, }; /** Lucide 아이콘 동적 렌더링 */ @@ -309,6 +367,7 @@ interface PopButtonComponentProps { label?: string; isDesignMode?: boolean; screenId?: string; + componentId?: string; } export function PopButtonComponent({ @@ -316,8 +375,8 @@ export function PopButtonComponent({ label, isDesignMode, screenId, + componentId, }: PopButtonComponentProps) { - // usePopAction 훅으로 액션 실행 통합 const { execute, isLoading, @@ -326,23 +385,127 @@ export function PopButtonComponent({ cancelConfirm, } = usePopAction(screenId || ""); - // 확인 메시지 결정 + const { subscribe, publish } = usePopEvent(screenId || "default"); + + // 장바구니 모드 상태 + const isCartMode = config?.preset === "cart"; + const [cartCount, setCartCount] = useState(0); + const [cartIsDirty, setCartIsDirty] = useState(false); + const [cartSaving, setCartSaving] = useState(false); + const [showCartConfirm, setShowCartConfirm] = useState(false); + + // 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달) + useEffect(() => { + if (!isCartMode || !componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__cart_updated`, + (payload: unknown) => { + const data = payload as { value?: { count?: number; isDirty?: boolean } } | undefined; + const inner = data?.value; + if (inner?.count !== undefined) setCartCount(inner.count); + if (inner?.isDirty !== undefined) setCartIsDirty(inner.isDirty); + } + ); + return unsub; + }, [isCartMode, componentId, subscribe]); + + // 저장 완료 수신 (카드 목록에서 saveToDb 완료 후 전달) + const cartScreenIdRef = React.useRef(config?.cart?.cartScreenId); + cartScreenIdRef.current = config?.cart?.cartScreenId; + + useEffect(() => { + if (!isCartMode || !componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__cart_save_completed`, + (payload: unknown) => { + const data = payload as { value?: { success?: boolean } } | undefined; + setCartSaving(false); + if (data?.value?.success) { + setCartIsDirty(false); + const targetScreenId = cartScreenIdRef.current; + if (targetScreenId) { + const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim(); + window.location.href = `/pop/screens/${cleanId}`; + } else { + toast.success("장바구니가 저장되었습니다."); + } + } else { + toast.error("장바구니 저장에 실패했습니다."); + } + } + ); + return unsub; + }, [isCartMode, componentId, subscribe]); + const getConfirmMessage = useCallback((): string => { if (pendingConfirm?.confirm?.message) return pendingConfirm.confirm.message; if (config?.confirm?.message) return config.confirm.message; return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]; }, [pendingConfirm?.confirm?.message, config?.confirm?.message, config?.action?.type]); + // 장바구니 저장 트리거 (연결 미설정 시 10초 타임아웃으로 복구) + const cartSaveTimeoutRef = React.useRef | null>(null); + + const handleCartSave = useCallback(() => { + if (!componentId) return; + setCartSaving(true); + const selectedCols = + config?.cart?.rowDataMode === "selected" ? config.cart.selectedColumns : undefined; + publish(`__comp_output__${componentId}__cart_save_trigger`, { + selectedColumns: selectedCols, + }); + + if (cartSaveTimeoutRef.current) clearTimeout(cartSaveTimeoutRef.current); + cartSaveTimeoutRef.current = setTimeout(() => { + setCartSaving((prev) => { + if (prev) { + toast.error("장바구니 저장 응답이 없습니다. 연결 설정을 확인하세요."); + } + return false; + }); + }, 10_000); + }, [componentId, publish, config?.cart?.rowDataMode, config?.cart?.selectedColumns]); + + // 저장 완료 시 타임아웃 정리 + useEffect(() => { + if (!cartSaving && cartSaveTimeoutRef.current) { + clearTimeout(cartSaveTimeoutRef.current); + cartSaveTimeoutRef.current = null; + } + }, [cartSaving]); + // 클릭 핸들러 const handleClick = useCallback(async () => { - // 디자인 모드: 실제 실행 안 함 if (isDesignMode) { toast.info( - `[디자인 모드] ${ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션` + `[디자인 모드] ${isCartMode ? "장바구니 저장" : ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션` ); return; } + // 장바구니 모드: isDirty 여부에 따라 분기 + if (isCartMode) { + if (cartCount === 0 && !cartIsDirty) { + toast.info("장바구니가 비어 있습니다."); + return; + } + + if (cartIsDirty) { + // 새로 담은 항목이 있음 → 확인 후 저장 + setShowCartConfirm(true); + } else { + // 이미 저장된 상태 → 바로 장바구니 화면 이동 + const targetScreenId = config?.cart?.cartScreenId; + if (targetScreenId) { + const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim(); + window.location.href = `/pop/screens/${cleanId}`; + } else { + toast.info("장바구니 화면이 설정되지 않았습니다."); + } + } + return; + } + const action = config?.action; if (!action) return; @@ -350,7 +513,7 @@ export function PopButtonComponent({ confirm: config?.confirm, followUpActions: config?.followUpActions, }); - }, [isDesignMode, config?.action, config?.confirm, config?.followUpActions, execute]); + }, [isDesignMode, isCartMode, config, cartCount, cartIsDirty, execute, handleCartSave]); // 외형 const buttonLabel = config?.label || label || "버튼"; @@ -358,30 +521,96 @@ export function PopButtonComponent({ const iconName = config?.icon || ""; const isIconOnly = config?.iconOnly || false; + // 장바구니 3상태 아이콘: 빈 장바구니 / 저장 완료 / 변경사항 있음 + const cartIconName = useMemo(() => { + if (!isCartMode) return iconName; + if (cartCount === 0 && !cartIsDirty) return "ShoppingCart"; + if (cartCount > 0 && !cartIsDirty) return "ShoppingBag"; + return "ShoppingCart"; + }, [isCartMode, cartCount, cartIsDirty, iconName]); + + // 장바구니 3상태 버튼 색상 + const cartButtonClass = useMemo(() => { + if (!isCartMode) return ""; + if (cartCount > 0 && !cartIsDirty) { + return "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600"; + } + if (cartIsDirty) { + return "bg-orange-500 hover:bg-orange-600 text-white border-orange-500 animate-pulse"; + } + return ""; + }, [isCartMode, cartCount, cartIsDirty]); + return ( <>
- + + {/* 장바구니 배지 */} + {isCartMode && cartCount > 0 && ( +
+ {cartCount} +
)} - > - {iconName && ( - - )} - {!isIconOnly && {buttonLabel}} - +
- {/* 확인 다이얼로그 (usePopAction의 pendingConfirm 상태로 제어) */} + {/* 장바구니 확인 다이얼로그 */} + + + + + 장바구니 저장 + + + {config?.confirm?.message || `${cartCount}개 항목을 장바구니에 저장하시겠습니까?`} + + + + + 취소 + + { + setShowCartConfirm(false); + handleCartSave(); + }} + className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" + > + 저장 + + + + + + {/* 일반 확인 다이얼로그 */} { if (!open) cancelConfirm(); }}> @@ -420,14 +649,117 @@ export function PopButtonComponent({ interface PopButtonConfigPanelProps { config: PopButtonConfig; onUpdate: (config: PopButtonConfig) => void; + allComponents?: { id: string; type: string; config?: Record }[]; + connections?: { sourceComponent: string; targetComponent: string; sourceOutput?: string; targetInput?: string }[]; + componentId?: string; } export function PopButtonConfigPanel({ config, onUpdate, + allComponents, + connections, + componentId, }: PopButtonConfigPanelProps) { const isCustom = config?.preset === "custom"; + // 컬럼 불러오기용 상태 + const [loadedColumns, setLoadedColumns] = useState<{ name: string; label: string }[]>([]); + const [colLoading, setColLoading] = useState(false); + const [connectedTableName, setConnectedTableName] = useState(null); + + // 연결된 카드 목록의 테이블명 자동 탐색 + useEffect(() => { + if (config?.preset !== "cart" || !componentId || !connections || !allComponents) { + setConnectedTableName(null); + return; + } + + // 방법 1: 버튼(source) -> 카드목록(target) cart_save_trigger 연결 + let cardListId: string | undefined; + const outConn = connections.find( + (c) => + c.sourceComponent === componentId && + c.sourceOutput === "cart_save_trigger", + ); + if (outConn) { + cardListId = outConn.targetComponent; + } + + // 방법 2: 카드목록(source) -> 버튼(target) cart_updated 연결 (역방향) + if (!cardListId) { + const inConn = connections.find( + (c) => + c.targetComponent === componentId && + (c.sourceOutput === "cart_updated" || c.sourceOutput === "cart_save_completed"), + ); + if (inConn) { + cardListId = inConn.sourceComponent; + } + } + + // 방법 3: 버튼과 연결된 pop-card-list 타입 컴포넌트 탐색 + if (!cardListId) { + const anyConn = connections.find( + (c) => + (c.sourceComponent === componentId || c.targetComponent === componentId), + ); + if (anyConn) { + const otherId = anyConn.sourceComponent === componentId + ? anyConn.targetComponent + : anyConn.sourceComponent; + const otherComp = allComponents.find((c) => c.id === otherId); + if (otherComp?.type === "pop-card-list") { + cardListId = otherId; + } + } + } + + if (!cardListId) { + setConnectedTableName(null); + return; + } + + const cardList = allComponents.find((c) => c.id === cardListId); + const cfg = cardList?.config as Record | undefined; + const dataSource = cfg?.dataSource as Record | undefined; + const tableName = (dataSource?.tableName as string) || (cfg?.tableName as string) || undefined; + setConnectedTableName(tableName || null); + }, [config?.preset, componentId, connections, allComponents]); + + // 선택 저장 모드 + 연결 테이블명이 있으면 컬럼 자동 로드 + useEffect(() => { + if (config?.cart?.rowDataMode !== "selected" || !connectedTableName) { + return; + } + // 이미 같은 테이블의 컬럼이 로드되어 있으면 스킵 + if (loadedColumns.length > 0) return; + + let cancelled = false; + setColLoading(true); + DataFlowAPI.getTableColumns(connectedTableName) + .then((cols) => { + if (cancelled) return; + setLoadedColumns( + cols + .filter((c: { columnName: string }) => + !["id", "created_at", "updated_at", "created_by", "updated_by"].includes(c.columnName), + ) + .map((c: { columnName: string; displayName?: string }) => ({ + name: c.columnName, + label: c.displayName || c.columnName, + })), + ); + }) + .catch(() => { + if (!cancelled) setLoadedColumns([]); + }) + .finally(() => { + if (!cancelled) setColLoading(false); + }); + return () => { cancelled = true; }; + }, [config?.cart?.rowDataMode, connectedTableName, loadedColumns.length]); + // 프리셋 변경 핸들러 const handlePresetChange = (preset: ButtonPreset) => { const defaults = PRESET_DEFAULTS[preset]; @@ -554,44 +886,203 @@ export function PopButtonConfigPanel({
- {/* 메인 액션 */} - -
- {/* 액션 타입 */} -
- - - {!isCustom && ( -

- 프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택 -

- )} -
+ {/* 장바구니 설정 (cart 프리셋 전용) */} + {config?.preset === "cart" && ( + <> + +
+
+ + + onUpdate({ + ...config, + cart: { ...config.cart, cartScreenId: e.target.value }, + }) + } + placeholder="저장 후 이동할 POP 화면 ID" + className="h-8 text-xs" + /> +

+ 저장 완료 후 이동할 장바구니 리스트 화면 ID입니다. + 비어있으면 이동 없이 저장만 합니다. +

+
+
- {/* 액션별 추가 설정 */} - -
+ {/* 데이터 저장 흐름 시각화 */} + +
+

+ 카드 목록에서 "담기" 클릭 시 아래와 같이 cart_items 테이블에 저장됩니다. +

+ +
+ {/* 사용자 입력 데이터 */} +
+

사용자 입력

+ + + + +
+ + {/* 원본 데이터 */} +
+

원본 행 데이터

+ + {/* 저장 모드 선택 */} +
+ 저장 모드: + +
+ + {config?.cart?.rowDataMode === "selected" ? ( + <> + {/* 선택 저장 모드: 컬럼 목록 관리 */} +
+ {connectedTableName ? ( +

+ 연결: {connectedTableName} +

+ ) : ( +

+ 카드 목록과 연결(cart_save_trigger)하면 컬럼이 자동으로 표시됩니다. +

+ )} + + {colLoading && ( +

컬럼 불러오는 중...

+ )} + + {/* 불러온 컬럼 체크박스 */} + {loadedColumns.length > 0 && ( +
+ {loadedColumns.map((col) => { + const isChecked = (config?.cart?.selectedColumns || []).includes(col.name); + return ( + + ); + })} +
+ )} + + {/* 선택된 컬럼 요약 */} + {(config?.cart?.selectedColumns?.length ?? 0) > 0 ? ( + + ) : ( +

+ 저장할 컬럼을 선택하세요. 미선택 시 전체 저장됩니다. +

+ )} +
+ + ) : ( + + )} + + + +
+ + {/* 시스템 자동 */} +
+

자동 설정

+ + + + + +
+
+ +

+ 장바구니 목록 화면에서 row_data의 JSON을 풀어서 + 최종 대상 테이블로 매핑할 수 있습니다. +

+
+ + )} + + {/* 메인 액션 (cart 프리셋에서는 숨김) */} + {config?.preset !== "cart" && ( + <> + +
+
+ + + {!isCustom && ( +

+ 프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택 +

+ )} +
+ +
+ + )} {/* 확인 다이얼로그 */} @@ -980,7 +1471,7 @@ function PopButtonPreviewComponent({ PopComponentRegistry.registerComponent({ id: "pop-button", name: "버튼", - description: "액션 버튼 (저장/삭제/API/모달/이벤트)", + description: "액션 버튼 (저장/삭제/API/모달/이벤트/장바구니)", category: "action", icon: "MousePointerClick", component: PopButtonComponent, @@ -993,6 +1484,15 @@ PopComponentRegistry.registerComponent({ confirm: { enabled: false }, action: { type: "save" }, } as PopButtonConfig, + connectionMeta: { + sendable: [ + { key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" }, + ], + receivable: [ + { key: "cart_updated", label: "장바구니 상태", type: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" }, + { key: "cart_save_completed", label: "저장 완료", type: "event", description: "카드 목록에서 DB 저장 완료 시 수신" }, + ], + }, touchOptimized: true, supportedDevices: ["mobile", "tablet"], }); diff --git a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx index 209234ec..6e818a0c 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx @@ -6,8 +6,10 @@ import { Dialog, DialogPortal, DialogOverlay, + DialogTitle, } from "@/components/ui/dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { PackageUnitModal, PACKAGE_UNITS, @@ -62,7 +64,7 @@ export function NumberInputModal({ () => entries.reduce((sum, e) => sum + e.totalQuantity, 0), [entries] ); - const remainingQuantity = maxValue - entriesTotal; + const remainingQuantity = Math.max(0, maxValue - entriesTotal); useEffect(() => { if (open) { @@ -81,7 +83,7 @@ export function NumberInputModal({ : step === "package_count" ? 9999 : step === "quantity_per_unit" - ? remainingQuantity > 0 ? remainingQuantity : maxValue + ? Math.max(1, remainingQuantity) : maxValue; const handleNumberClick = (num: string) => { @@ -117,7 +119,7 @@ export function NumberInputModal({ if (step === "quantity_per_unit") { if (numericValue <= 0 || !selectedUnit) return; - const total = packageCount * numericValue; + const total = Math.min(packageCount * numericValue, remainingQuantity); const newEntry: PackageEntry = { unitId: selectedUnit.id, unitLabel: selectedUnit.label, @@ -228,6 +230,7 @@ export function NumberInputModal({ + 수량 입력 {/* 헤더 */}
diff --git a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx index ad050744..bc32805c 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx @@ -2,12 +2,14 @@ import React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { X } from "lucide-react"; import { Dialog, DialogPortal, DialogOverlay, DialogClose, + DialogTitle, } from "@/components/ui/dialog"; import type { CustomPackageUnit } from "../types"; @@ -60,8 +62,9 @@ export function PackageUnitModal({ + 포장 단위 선택
-

📦 포장 단위 선택

+

포장 단위 선택

diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index 9d21a8d4..e3e7dc4c 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -35,6 +35,7 @@ import { } from "../types"; import { dataApi } from "@/lib/api/data"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { useCartSync } from "@/hooks/pop/useCartSync"; import { NumberInputModal } from "./NumberInputModal"; const LUCIDE_ICON_MAP: Record = { @@ -163,9 +164,14 @@ export function PopCardListComponent({ const dataSource = config?.dataSource; const template = config?.cardTemplate; - const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default"); + const { subscribe, publish } = usePopEvent(screenId || "default"); const router = useRouter(); + // 장바구니 DB 동기화 + const sourceTableName = dataSource?.tableName || ""; + const cartType = config?.cartAction?.cartType; + const cart = useCartSync(screenId || "", sourceTableName, cartType); + // 데이터 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); @@ -209,6 +215,35 @@ export function PopCardListComponent({ return unsub; }, [componentId, subscribe]); + // cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용 + const cartRef = useRef(cart); + cartRef.current = cart; + + // "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__cart_save_trigger`, + async (payload: unknown) => { + const data = payload as { value?: { selectedColumns?: string[] } } | undefined; + const ok = await cartRef.current.saveToDb(data?.value?.selectedColumns); + publish(`__comp_output__${componentId}__cart_save_completed`, { + success: ok, + }); + } + ); + return unsub; + }, [componentId, subscribe, publish]); + + // DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 + useEffect(() => { + if (!componentId || cart.loading) return; + publish(`__comp_output__${componentId}__cart_updated`, { + count: cart.cartCount, + isDirty: cart.isDirty, + }); + }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish]); + // 카드 선택 시 selected_row 이벤트 발행 const handleCardSelect = useCallback((row: RowData) => { if (!componentId) return; @@ -229,7 +264,9 @@ export function PopCardListComponent({ useEffect(() => { if (!containerRef.current) return; const observer = new ResizeObserver((entries) => { - const { width, height } = entries[0].contentRect; + const entry = entries[0]; + if (!entry) return; + const { width, height } = entry.contentRect; if (width > 0) setContainerWidth(width); if (height > 0) setContainerHeight(height); }); @@ -239,7 +276,7 @@ export function PopCardListComponent({ // 이미지 URL 없는 항목 카운트 (toast 중복 방지용) const missingImageCountRef = useRef(0); - const toastShownRef = useRef(false); + const cardSizeKey = config?.cardSize || "large"; const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large; @@ -256,7 +293,7 @@ export function PopCardListComponent({ const autoColumns = containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap))) : maxGridColumns; - const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns); + const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns)); // 행 수: 설정값 그대로 사용 (containerHeight 연동 시 피드백 루프 위험) const gridRows = configGridRows; @@ -428,7 +465,6 @@ export function PopCardListComponent({ setLoading(true); setError(null); missingImageCountRef.current = 0; - toastShownRef.current = false; try { const filters: Record = {}; @@ -476,25 +512,11 @@ export function PopCardListComponent({ fetchData(); }, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps - // 이미지 URL 없는 항목 체크 및 toast 표시 + // 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시) useEffect(() => { - if ( - !loading && - rows.length > 0 && - template?.image?.enabled && - template?.image?.imageColumn && - !toastShownRef.current - ) { + if (!loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn) { const imageColumn = template.image.imageColumn; - const missingCount = rows.filter((row) => !row[imageColumn]).length; - - if (missingCount > 0) { - missingImageCountRef.current = missingCount; - toastShownRef.current = true; - toast.warning( - `${missingCount}개 항목의 이미지 URL이 없어 기본 이미지로 표시됩니다` - ); - } + missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length; } }, [loading, rows, template?.image]); @@ -558,9 +580,10 @@ export function PopCardListComponent({ }} > {displayCards.map((row, index) => { - const rowKey = template?.header?.codeField && row[template.header.codeField] + const codeValue = template?.header?.codeField && row[template.header.codeField] ? String(row[template.header.codeField]) - : `card-${index}`; + : null; + const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`; return ( ); })} @@ -652,10 +676,11 @@ function Card({ packageConfig, cartAction, publish, - getSharedData, - setSharedData, router, onSelect, + cart, + codeFieldName, + parentComponentId, }: { row: RowData; template?: CardTemplateConfig; @@ -664,10 +689,11 @@ function Card({ packageConfig?: CardPackageConfig; cartAction?: CardCartActionConfig; publish: (eventName: string, payload?: unknown) => void; - getSharedData: (key: string) => T | undefined; - setSharedData: (key: string, value: unknown) => void; router: ReturnType; onSelect?: (row: RowData) => void; + cart: ReturnType; + codeFieldName?: string; + parentComponentId?: string; }) { const header = template?.header; const image = template?.image; @@ -677,11 +703,24 @@ function Card({ const [packageUnit, setPackageUnit] = useState(undefined); const [packageEntries, setPackageEntries] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); - const [isCarted, setIsCarted] = useState(false); const codeValue = header?.codeField ? row[header.codeField] : null; const titleValue = header?.titleField ? row[header.titleField] : null; + // 장바구니 상태: codeField 값을 rowKey로 사용 + const rowKey = codeFieldName && row[codeFieldName] ? String(row[codeFieldName]) : ""; + const isCarted = cart.isItemInCart(rowKey); + const existingCartItem = cart.getCartItem(rowKey); + + // DB에서 로드된 장바구니 품목이면 입력값 복원 + useEffect(() => { + if (existingCartItem && existingCartItem._origin === "db") { + setInputValue(existingCartItem.quantity); + setPackageUnit(existingCartItem.packageUnit); + setPackageEntries(existingCartItem.packageEntries || []); + } + }, [existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); + const imageUrl = image?.enabled && image?.imageColumn && row[image.imageColumn] ? String(row[image.imageColumn]) @@ -734,8 +773,10 @@ function Card({ setPackageEntries(entries || []); }; - // 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글 + // 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달 const handleCartAdd = () => { + if (!rowKey) return; + const cartItem: CartItem = { row, quantity: inputValue, @@ -743,30 +784,26 @@ function Card({ packageEntries: packageEntries.length > 0 ? packageEntries : undefined, }; - const existing = getSharedData("cart_items") || []; - setSharedData("cart_items", [...existing, cartItem]); - publish("cart_item_added", cartItem); - - setIsCarted(true); - toast.success("장바구니에 담겼습니다."); - - if (cartAction?.navigateMode === "screen" && cartAction.targetScreenId) { - router.push(`/pop/screens/${cartAction.targetScreenId}`); + cart.addItem(cartItem, rowKey); + if (parentComponentId) { + publish(`__comp_output__${parentComponentId}__cart_updated`, { + count: cart.cartCount + 1, + isDirty: true, + }); } }; - // 취소: sharedData에서 해당 아이템 제거 + 이벤트 발행 + 토글 복원 + // 취소: 로컬 상태에서만 제거 + 연결 시스템으로 카운트 전달 const handleCartCancel = () => { - const existing = getSharedData("cart_items") || []; - const rowKey = JSON.stringify(row); - const filtered = existing.filter( - (item) => JSON.stringify(item.row) !== rowKey - ); - setSharedData("cart_items", filtered); - publish("cart_item_removed", { row }); + if (!rowKey) return; - setIsCarted(false); - toast.info("장바구니에서 제거되었습니다."); + cart.removeItem(rowKey); + if (parentComponentId) { + publish(`__comp_output__${parentComponentId}__cart_updated`, { + count: Math.max(0, cart.cartCount - 1), + isDirty: true, + }); + } }; // pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영) @@ -780,7 +817,11 @@ function Card({ return (
{/* 헤더 영역 */} {(codeValue !== null || titleValue !== null) && ( -
+
{codeValue !== null && ( {/* 담기 버튼 설정 */} - + onUpdate({ cartAction })} + cardTemplate={template} + tableName={dataSource.tableName} /> @@ -2136,14 +2138,16 @@ function LimitSettingsSection({ function CartActionSettingsSection({ cartAction, onUpdate, + cardTemplate, + tableName, }: { cartAction?: CardCartActionConfig; onUpdate: (cartAction: CardCartActionConfig) => void; + cardTemplate?: CardTemplateConfig; + tableName?: string; }) { const action: CardCartActionConfig = cartAction || { - navigateMode: "none", - iconType: "lucide", - iconValue: "ShoppingCart", + saveMode: "cart", label: "담기", cancelLabel: "취소", }; @@ -2152,82 +2156,63 @@ function CartActionSettingsSection({ onUpdate({ ...action, ...partial }); }; + const saveMode = action.saveMode || "cart"; + + // 카드 템플릿에서 사용 중인 필드 목록 수집 + const usedFields = useMemo(() => { + const fields: { name: string; label: string; source: string }[] = []; + + if (cardTemplate?.header?.codeField) { + fields.push({ name: cardTemplate.header.codeField, label: "코드 (헤더)", source: "헤더" }); + } + if (cardTemplate?.header?.titleField) { + fields.push({ name: cardTemplate.header.titleField, label: "제목 (헤더)", source: "헤더" }); + } + if (cardTemplate?.body?.fields) { + for (const f of cardTemplate.body.fields) { + if (f.valueType === "column" && f.columnName) { + fields.push({ name: f.columnName, label: f.label || f.columnName, source: "본문" }); + } + } + } + return fields; + }, [cardTemplate]); + return (
- {/* 네비게이션 모드 */} + {/* 저장 방식 */}
- +
- {/* 대상 화면 ID (screen 모드일 때만) */} - {action.navigateMode === "screen" && ( + {/* 장바구니 구분값 */} + {saveMode === "cart" && (
- + update({ targetScreenId: e.target.value })} - placeholder="예: 15" + value={action.cartType || ""} + onChange={(e) => update({ cartType: e.target.value })} + placeholder="예: purchase_inbound" className="mt-1 h-7 text-xs" />

- 담기 클릭 시 이동할 POP 화면의 screenId + 장바구니 화면에서 이 값으로 필터링하여 해당 품목만 표시합니다.

)} - {/* 아이콘 타입 */} -
- - -
- - {/* 아이콘 값 */} -
- - update({ iconValue: e.target.value })} - placeholder={ - action.iconType === "emoji" ? "예: 🛒" : "예: ShoppingCart" - } - className="mt-1 h-7 text-xs" - /> - {action.iconType === "lucide" && ( -

- PascalCase로 입력 (ShoppingCart, Package, Truck 등) -

- )} -
- {/* 담기 라벨 */}
@@ -2249,6 +2234,40 @@ function CartActionSettingsSection({ className="mt-1 h-7 text-xs" />
+ + {/* 저장 데이터 정보 (읽기 전용) */} +
+ +
+

+ 담기 시 {tableName || "(테이블 미선택)"}의 + 모든 컬럼 데이터가 JSON으로 저장됩니다. +

+ + {usedFields.length > 0 && ( +
+

카드에 표시 중인 필드:

+
+ {usedFields.map((f) => ( +
+ + {f.source} + + {f.name} + - {f.label} +
+ ))} +
+
+ )} + +

+ + 입력 수량, 포장 단위 등 추가 정보도 함께 저장됩니다. +
+ 장바구니 목록 화면에서 대상 테이블로 매핑 설정이 가능합니다. +

+
+
); } diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx index aea93555..738dfa4c 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -41,9 +41,7 @@ const defaultConfig: PopCardListConfig = { gridRows: 2, // 담기 버튼 기본 설정 cartAction: { - navigateMode: "none", - iconType: "lucide", - iconValue: "ShoppingCart", + saveMode: "cart", label: "담기", cancelLabel: "취소", }, @@ -63,10 +61,12 @@ PopComponentRegistry.registerComponent({ connectionMeta: { sendable: [ { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" }, - { key: "cart_item_added", label: "담기 완료", type: "event", description: "카드 담기 시 해당 행 + 수량 데이터 전달" }, + { key: "cart_updated", label: "장바구니 상태", type: "event", description: "장바구니 변경 시 count/isDirty 전달" }, + { key: "cart_save_completed", label: "저장 완료", type: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, ], receivable: [ { key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, + { key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index 3993dc48..96507984 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -13,7 +13,20 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react"; +import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import type { PopSearchConfig, SearchInputType, @@ -379,6 +392,7 @@ function ModalDetailSettings({ cfg, update }: StepProps) { const [columns, setColumns] = useState([]); const [tablesLoading, setTablesLoading] = useState(false); const [columnsLoading, setColumnsLoading] = useState(false); + const [openTableCombo, setOpenTableCombo] = useState(false); useEffect(() => { let cancelled = false; @@ -455,23 +469,62 @@ function ModalDetailSettings({ cfg, update }: StepProps) { 테이블 목록 로딩...
) : ( - + + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((t) => ( + { + updateModal({ + tableName: t.tableName, + displayColumns: [], + searchColumns: [], + displayField: "", + valueField: "", + columnLabels: undefined, + }); + setOpenTableCombo(false); + }} + className="text-xs" + > + +
+ {t.displayName || t.tableName} + {t.displayName && t.displayName !== t.tableName && ( + {t.tableName} + )} +
+
+ ))} +
+
+
+
+
)}
diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index a9a0a83a..d3c77233 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -491,7 +491,7 @@ export interface PackageEntry { totalQuantity: number; // 합계 = packageCount * quantityPerUnit } -// ----- 담기 버튼 데이터 구조 (추후 API 연동 시 이 shape 그대로 사용) ----- +// ----- 담기 버튼 데이터 구조 (로컬 상태용) ----- export interface CartItem { row: Record; // 카드 원본 행 데이터 @@ -500,15 +500,39 @@ export interface CartItem { packageEntries?: PackageEntry[]; // 포장 내역 (2단계 계산 시) } +// ----- 장바구니 DB 연동용 확장 타입 ----- + +export type CartSyncStatus = "clean" | "dirty" | "saving"; +export type CartItemOrigin = "db" | "local"; +export type CartItemStatus = "in_cart" | "confirmed" | "cancelled"; + +export interface CartItemWithId extends CartItem { + cartId?: string; // DB id (UUID, 저장 후 할당) + sourceTable: string; // 원본 테이블명 + rowKey: string; // 원본 행 식별키 (codeField 값) + status: CartItemStatus; + _origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가 + memo?: string; +} + + // ----- 담기 버튼 액션 설정 (pop-icon 스타일 + 장바구니 연동) ----- + +export type CartSaveMode = "cart" | "direct"; + export interface CardCartActionConfig { - navigateMode: "none" | "screen"; // 담기 후 이동 모드 - targetScreenId?: string; // 장바구니 POP 화면 ID (screen 모드) - iconType?: "lucide" | "emoji"; // 아이콘 타입 - iconValue?: string; // Lucide 아이콘명 또는 이모지 값 - label?: string; // 담기 라벨 (기본: "담기") - cancelLabel?: string; // 취소 라벨 (기본: "취소") + saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장 + cartType?: string; // 장바구니 구분값 (예: "purchase_inbound") + label?: string; // 담기 라벨 (기본: "담기") + cancelLabel?: string; // 취소 라벨 (기본: "취소") + // 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호) + dataFields?: { sourceField: string; targetField?: string; label?: string }[]; + // 하위 호환: 기존 필드 (사용하지 않지만 기존 데이터 보호) + navigateMode?: "none" | "screen"; + targetScreenId?: string; + iconType?: "lucide" | "emoji"; + iconValue?: string; } // ----- pop-card-list 전체 설정 ----- diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index f6f1907e..22b80896 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -24,7 +24,7 @@ const nextConfig = { // 로컬 개발: http://127.0.0.1:8080 사용 async rewrites() { // Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 - const backendUrl = process.env.SERVER_API_URL || "http://pms-backend-mac:8080"; + const backendUrl = process.env.SERVER_API_URL || "http://localhost:8080"; return [ { source: "/api/:path*", @@ -50,7 +50,7 @@ const nextConfig = { // 환경 변수 (런타임에 읽기) env: { // Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://pms-backend-mac:8080/api", + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api", }, }; From afc66a4971cc55e505b5b529251ff23f64edf31d Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 26 Feb 2026 17:07:53 +0900 Subject: [PATCH 03/15] feat: Enhance SelectedItemsDetailInputComponent with improved FK mapping and performance optimizations - Implemented automatic detection of sourceKeyField based on component configuration, enhancing data handling flexibility. - Updated SelectedItemsDetailInputConfigPanel to support automatic FK detection and mapping, streamlining configuration. - Improved database connection logic for DATE types to prevent timezone-related issues. - Optimized memoization and state management for better overall component performance and user experience. --- bom-restore-verify.mjs | 85 +++++++++++++++++++ .../app/(main)/screen/[screenCode]/page.tsx | 14 +-- 2 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 bom-restore-verify.mjs diff --git a/bom-restore-verify.mjs b/bom-restore-verify.mjs new file mode 100644 index 00000000..15407bde --- /dev/null +++ b/bom-restore-verify.mjs @@ -0,0 +1,85 @@ +/** + * BOM Screen - Restoration Verification + * Screen 4168 - verify split panel, BOM list, and tree with child items + */ +import { chromium } from 'playwright'; +import { mkdirSync, existsSync } from 'fs'; +import { join } from 'path'; + +const SCREENSHOT_DIR = join(process.cwd(), 'bom-detail-test-screenshots'); + +async function ensureDir(dir) { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); +} + +async function screenshot(page, name) { + ensureDir(SCREENSHOT_DIR); + await page.screenshot({ path: join(SCREENSHOT_DIR, `${name}.png`), fullPage: true }); + console.log(` [Screenshot] ${name}.png`); +} + +async function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + + try { + console.log('\n--- Step 1-2: Login ---'); + await page.goto('http://localhost:9771/login', { waitUntil: 'load', timeout: 45000 }); + await page.locator('input[type="text"], input[placeholder*="ID"]').first().fill('topseal_admin'); + await page.locator('input[type="password"]').first().fill('qlalfqjsgh11'); + await Promise.all([ + page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {}), + page.locator('button:has-text("로그인")').first().click(), + ]); + await sleep(3000); + + console.log('\n--- Step 4-5: Navigate to screen 4168 ---'); + await page.goto('http://localhost:9771/screens/4168', { waitUntil: 'load', timeout: 45000 }); + await sleep(5000); + + console.log('\n--- Step 6: Screenshot after load ---'); + await screenshot(page, '10-bom-4168-initial'); + + const hasBomList = (await page.locator('text="BOM 목록"').count()) > 0; + const hasSplitPanel = (await page.locator('text="BOM 상세정보"').count()) > 0 || hasBomList; + const rowCount = await page.locator('table tbody tr').count(); + const hasBomRows = rowCount > 0; + + console.log('\n========== INITIAL STATE (Step 7) =========='); + console.log('BOM management screen loaded:', hasBomList || hasSplitPanel ? 'YES' : 'CHECK'); + console.log('Split panel (BOM list left):', hasSplitPanel ? 'YES' : 'NO'); + console.log('BOM data rows visible:', hasBomRows ? `YES (${rowCount} rows)` : 'NO'); + + if (hasBomRows) { + console.log('\n--- Step 8-9: Click first row ---'); + await page.locator('table tbody tr').first().click(); + await sleep(5000); + + console.log('\n--- Step 10: Screenshot after row click ---'); + await screenshot(page, '11-bom-4168-after-click'); + + const noDataMsg = (await page.locator('text="등록된 하위 품목이 없습니다"').count()) > 0; + const treeArea = page.locator('div:has-text("BOM 구성"), div:has-text("BOM 상세정보")').first(); + const treeText = (await treeArea.textContent().catch(() => '') || '').substring(0, 600); + const hasChildItems = !noDataMsg && (treeText.includes('품번') || treeText.includes('레벨') || treeText.length > 150); + + console.log('\n========== AFTER ROW CLICK (Step 11) =========='); + console.log('BOM tree shows child items:', hasChildItems ? 'YES' : noDataMsg ? 'NO (empty message)' : 'CHECK'); + console.log('Tree preview:', treeText.substring(0, 300) + (treeText.length > 300 ? '...' : '')); + } else { + console.log('\n--- No BOM rows to click ---'); + } + + } catch (err) { + console.error('Error:', err.message); + try { await page.screenshot({ path: join(SCREENSHOT_DIR, '99-error.png'), fullPage: true }); } catch (e) {} + } finally { + await browser.close(); + } +} + +main(); diff --git a/frontend/app/(main)/screen/[screenCode]/page.tsx b/frontend/app/(main)/screen/[screenCode]/page.tsx index 0817065e..64c1bb34 100644 --- a/frontend/app/(main)/screen/[screenCode]/page.tsx +++ b/frontend/app/(main)/screen/[screenCode]/page.tsx @@ -6,7 +6,7 @@ import { Loader2 } from "lucide-react"; import { apiClient } from "@/lib/api/client"; /** - * /screen/COMPANY_7_167 → /screens/4153 리다이렉트 + * /screen/{screenCode} → /screens/{screenId} 리다이렉트 * 메뉴 URL이 screenCode 기반이므로, screenId로 변환 후 이동 */ export default function ScreenCodeRedirectPage() { @@ -26,12 +26,14 @@ export default function ScreenCodeRedirectPage() { const resolve = async () => { try { const res = await apiClient.get("/screen-management/screens", { - params: { screenCode }, + params: { searchTerm: screenCode, size: 50 }, }); - const screens = res.data?.data || []; - if (screens.length > 0) { - const id = screens[0].screenId || screens[0].screen_id; - router.replace(`/screens/${id}`); + const items = res.data?.data?.data || res.data?.data || []; + const arr = Array.isArray(items) ? items : []; + const exact = arr.find((s: any) => s.screenCode === screenCode); + const target = exact || arr[0]; + if (target) { + router.replace(`/screens/${target.screenId || target.screen_id}`); } else { router.replace("/"); } From 385a10e2e77a61596b8c14dbcca6acd477f17948 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 26 Feb 2026 20:48:56 +0900 Subject: [PATCH 04/15] feat: Add BOM version initialization feature and enhance version handling - Implemented a new endpoint to initialize BOM versions, automatically creating the first version and updating related details. - Enhanced the BOM service to include logic for version name handling and duplication checks during version creation. - Updated the BOM controller to support the new initialization functionality, improving BOM management capabilities. - Improved the BOM version modal to allow users to specify version names during creation, enhancing user experience and flexibility. --- backend-node/src/controllers/bomController.ts | 18 ++- backend-node/src/routes/bomRoutes.ts | 1 + backend-node/src/services/bomService.ts | 90 ++++++++++-- .../BomItemEditorComponent.tsx | 129 +++++++++++------- .../v2-bom-tree/BomDetailEditModal.tsx | 55 +++++++- .../v2-bom-tree/BomTreeComponent.tsx | 40 +++++- .../v2-bom-tree/BomVersionModal.tsx | 70 ++++++++-- 7 files changed, 321 insertions(+), 82 deletions(-) diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts index 8355b148..3508fca4 100644 --- a/backend-node/src/controllers/bomController.ts +++ b/backend-node/src/controllers/bomController.ts @@ -92,9 +92,9 @@ export async function createBomVersion(req: Request, res: Response) { const { bomId } = req.params; const companyCode = (req as any).user?.companyCode || "*"; const createdBy = (req as any).user?.userName || (req as any).user?.userId || ""; - const { tableName, detailTable } = req.body || {}; + const { tableName, detailTable, versionName } = req.body || {}; - const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable); + const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable, versionName); res.json({ success: true, data: result }); } catch (error: any) { logger.error("BOM 버전 생성 실패", { error: error.message }); @@ -129,6 +129,20 @@ export async function activateBomVersion(req: Request, res: Response) { } } +export async function initializeBomVersion(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const createdBy = (req as any).user?.userName || (req as any).user?.userId || ""; + + const result = await bomService.initializeBomVersion(bomId, companyCode, createdBy); + 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 deleteBomVersion(req: Request, res: Response) { try { const { bomId, versionId } = req.params; diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts index f6e3ee62..4aa8838d 100644 --- a/backend-node/src/routes/bomRoutes.ts +++ b/backend-node/src/routes/bomRoutes.ts @@ -20,6 +20,7 @@ router.post("/:bomId/history", bomController.addBomHistory); // 버전 router.get("/:bomId/versions", bomController.getBomVersions); router.post("/:bomId/versions", bomController.createBomVersion); +router.post("/:bomId/initialize-version", bomController.initializeBomVersion); router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion); router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion); router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion); diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index 89da38a9..b5cff246 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -98,6 +98,7 @@ export async function getBomVersions(bomId: string, companyCode: string, tableNa export async function createBomVersion( bomId: string, companyCode: string, createdBy: string, versionTableName?: string, detailTableName?: string, + inputVersionName?: string, ) { const vTable = safeTableName(versionTableName || "", "bom_version"); const dTable = safeTableName(detailTableName || "", "bom_detail"); @@ -107,17 +108,24 @@ export async function createBomVersion( if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다"); const bomData = bomRow.rows[0]; - // 다음 버전 번호 결정 - const lastVersion = await client.query( - `SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`, - [bomId], - ); - let nextVersionNum = 1; - if (lastVersion.rows.length > 0) { - const parsed = parseFloat(lastVersion.rows[0].version_name); - if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1; + // 버전명: 사용자 입력 > 순번 자동 생성 + let versionName = inputVersionName?.trim(); + if (!versionName) { + const countResult = await client.query( + `SELECT COUNT(*)::int as cnt FROM ${vTable} WHERE bom_id = $1`, + [bomId], + ); + versionName = `${(countResult.rows[0].cnt || 0) + 1}.0`; + } + + // 중복 체크 + const dupCheck = await client.query( + `SELECT id FROM ${vTable} WHERE bom_id = $1 AND version_name = $2`, + [bomId, versionName], + ); + if (dupCheck.rows.length > 0) { + throw new Error(`이미 존재하는 버전명입니다: ${versionName}`); } - const versionName = `${nextVersionNum}.0`; // 새 버전 레코드 생성 (snapshot_data 없이) const insertSql = ` @@ -249,6 +257,68 @@ export async function activateBomVersion(bomId: string, versionId: string, table }); } +/** + * 신규 BOM 초기화: 첫 번째 버전 자동 생성 + version_id null인 디테일 보정 + * BOM 헤더의 version 필드를 그대로 버전명으로 사용 (사용자 입력값 존중) + */ +export async function initializeBomVersion( + bomId: string, companyCode: string, createdBy: string, +) { + return transaction(async (client) => { + const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]); + if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다"); + const bomData = bomRow.rows[0]; + + if (bomData.current_version_id) { + await client.query( + `UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`, + [bomData.current_version_id, bomId], + ); + return { versionId: bomData.current_version_id, created: false }; + } + + // 이미 버전 레코드가 존재하는지 확인 (동시 호출 방지) + const existingVersion = await client.query( + `SELECT id, version_name FROM bom_version WHERE bom_id = $1 ORDER BY created_date ASC LIMIT 1`, + [bomId], + ); + if (existingVersion.rows.length > 0) { + const existId = existingVersion.rows[0].id; + await client.query( + `UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`, + [existId, bomId], + ); + await client.query( + `UPDATE bom SET current_version_id = $1 WHERE id = $2 AND current_version_id IS NULL`, + [existId, bomId], + ); + return { versionId: existId, created: false }; + } + + const versionName = bomData.version || "1.0"; + + const versionResult = await client.query( + `INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code) + VALUES ($1, $2, 0, 'active', $3, $4) RETURNING id`, + [bomId, versionName, createdBy, companyCode], + ); + const versionId = versionResult.rows[0].id; + + const updated = await client.query( + `UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`, + [versionId, bomId], + ); + + await client.query( + `UPDATE bom SET current_version_id = $1 WHERE id = $2`, + [versionId, bomId], + ); + + logger.info("BOM 초기 버전 생성", { bomId, versionId, versionName, updatedDetails: updated.rowCount }); + return { versionId, versionName, created: true }; + }); +} + /** * 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제 */ diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index e4521ac0..75c1909b 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -86,6 +86,7 @@ interface ItemSearchModalProps { onClose: () => void; onSelect: (items: ItemInfo[]) => void; companyCode?: string; + existingItemIds?: Set; } function ItemSearchModal({ @@ -93,6 +94,7 @@ function ItemSearchModal({ onClose, onSelect, companyCode, + existingItemIds, }: ItemSearchModalProps) { const [searchText, setSearchText] = useState(""); const [items, setItems] = useState([]); @@ -182,7 +184,7 @@ function ItemSearchModal({
) : ( - + - {items.map((item) => ( - { - setSelectedItems((prev) => { - const next = new Set(prev); - if (next.has(item.id)) next.delete(item.id); - else next.add(item.id); - return next; - }); - }} - className={cn( - "cursor-pointer border-t transition-colors", - selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent", - )} - > - - - - - - - ))} + {items.map((item) => { + const alreadyAdded = existingItemIds?.has(item.id) || false; + return ( + { + if (alreadyAdded) return; + setSelectedItems((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); + else next.add(item.id); + return next; + }); + }} + className={cn( + "border-t transition-colors", + alreadyAdded + ? "cursor-not-allowed opacity-40" + : "cursor-pointer", + !alreadyAdded && selectedItems.has(item.id) ? "bg-primary/10" : !alreadyAdded ? "hover:bg-accent" : "", + )} + > + + + + + + + ); + })}
e.stopPropagation()}> - { - setSelectedItems((prev) => { - const next = new Set(prev); - if (checked) next.add(item.id); - else next.delete(item.id); - return next; - }); - }} - /> - - {item.item_number} - {item.item_name}{item.type}{item.unit}
e.stopPropagation()}> + { + if (alreadyAdded) return; + setSelectedItems((prev) => { + const next = new Set(prev); + if (checked) next.add(item.id); + else next.delete(item.id); + return next; + }); + }} + /> + + {item.item_number} + {alreadyAdded && (추가됨)} + {item.item_name}{item.type}{item.unit}
)} @@ -739,37 +751,40 @@ export function BomItemEditorComponent({ [originalNotifyChange, markChanged], ); + const handleSaveAllRef = React.useRef<(() => Promise) | null>(null); + // EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장 useEffect(() => { if (isDesignMode || !bomId) return; const handler = (e: Event) => { const detail = (e as CustomEvent).detail; - console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", { - bomId, - treeDataLength: treeData.length, - hasRef: !!handleSaveAllRef.current, - }); - if (treeData.length > 0 && handleSaveAllRef.current) { + if (handleSaveAllRef.current) { const savePromise = handleSaveAllRef.current(); if (detail?.pendingPromises) { detail.pendingPromises.push(savePromise); - console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료"); } } }; window.addEventListener("beforeFormSave", handler); - console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode }); return () => window.removeEventListener("beforeFormSave", handler); - }, [isDesignMode, bomId, treeData.length]); - - const handleSaveAllRef = React.useRef<(() => Promise) | null>(null); + }, [isDesignMode, bomId]); const handleSaveAll = useCallback(async () => { if (!bomId) return; setSaving(true); try { - // 저장 시점에도 최신 version_id 조회 - const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId; + // version_id 확보: 없으면 서버에서 자동 초기화 + let saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId; + if (!saveVersionId) { + try { + const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`); + if (initRes.data?.success && initRes.data.data?.versionId) { + saveVersionId = initRes.data.data.versionId; + } + } catch (e) { + console.error("[BomItemEditor] 버전 초기화 실패:", e); + } + } const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => { const result: any[] = []; @@ -1338,6 +1353,18 @@ export function BomItemEditorComponent({ onClose={() => setItemSearchOpen(false)} onSelect={handleItemSelect} companyCode={companyCode} + existingItemIds={useMemo(() => { + const ids = new Set(); + const collect = (nodes: BomItemNode[]) => { + for (const n of nodes) { + const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"]; + if (fk) ids.add(fk); + collect(n.children); + } + }; + collect(treeData); + return ids; + }, [treeData, cfg])} />
); diff --git a/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx index cfff4a0c..6b5d4a40 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx @@ -13,6 +13,13 @@ 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 { Loader2 } from "lucide-react"; import { apiClient } from "@/lib/api/client"; @@ -35,6 +42,20 @@ export function BomDetailEditModal({ }: BomDetailEditModalProps) { const [formData, setFormData] = useState>({}); const [saving, setSaving] = useState(false); + const [processOptions, setProcessOptions] = useState<{ value: string; label: string }[]>([]); + + useEffect(() => { + if (open && !isRootNode) { + apiClient.get("/table-categories/bom_detail/process_type/values") + .then((res) => { + const values = res.data?.data || []; + if (values.length > 0) { + setProcessOptions(values.map((v: any) => ({ value: v.value_code, label: v.value_label }))); + } + }) + .catch(() => { /* 카테고리 없으면 빈 배열 유지 */ }); + } + }, [open, isRootNode]); useEffect(() => { if (node && open) { @@ -67,11 +88,15 @@ export function BomDetailEditModal({ try { const targetTable = isRootNode ? "bom" : tableName; const realId = isRootNode ? node.id?.replace("__root_", "") : node.id; - await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData); + await apiClient.put(`/table-management/tables/${targetTable}/edit`, { + originalData: { id: realId }, + updatedData: { id: realId, ...formData }, + }); onSaved?.(); onOpenChange(false); } catch (error) { console.error("[BomDetailEdit] 저장 실패:", error); + alert("저장 중 오류가 발생했습니다."); } finally { setSaving(false); } @@ -139,12 +164,28 @@ export function BomDetailEditModal({
- handleChange("process_type", e.target.value)} - placeholder="예: 조립공정" - className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" - /> + {processOptions.length > 0 ? ( + + ) : ( + handleChange("process_type", e.target.value)} + placeholder="예: 조립공정" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> + )}
diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 957b8d85..5234a74d 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -138,6 +138,23 @@ export function BomTreeComponent({ const showHistory = features.showHistory !== false; const showVersion = features.showVersion !== false; + // 카테고리 라벨 캐시 (process_type 등) + const [categoryLabels, setCategoryLabels] = useState>>({}); + useEffect(() => { + const loadLabels = async () => { + try { + const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values`); + const vals = res.data?.data || []; + if (vals.length > 0) { + const map: Record = {}; + vals.forEach((v: any) => { map[v.value_code] = v.value_label; }); + setCategoryLabels((prev) => ({ ...prev, process_type: map })); + } + } catch { /* 무시 */ } + }; + loadLabels(); + }, [detailTable]); + // ─── 데이터 로드 ─── // BOM 헤더 데이터로 가상 0레벨 루트 노드 생성 @@ -168,7 +185,18 @@ export function BomTreeComponent({ setLoading(true); try { const searchFilter: Record = { [foreignKey]: bomId }; - const versionId = headerData?.current_version_id; + let versionId = headerData?.current_version_id; + + // version_id가 없으면 서버에서 자동 초기화 + if (!versionId) { + try { + const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`); + if (initRes.data?.success && initRes.data.data?.versionId) { + versionId = initRes.data.data.versionId; + } + } catch { /* 무시 */ } + } + if (versionId) { searchFilter.version_id = versionId; } @@ -461,6 +489,11 @@ export function BomTreeComponent({ return {value || "-"}; } + if (col.key === "status") { + const statusMap: Record = { active: "사용", inactive: "미사용", developing: "개발중" }; + return {statusMap[String(value)] || value || "-"}; + } + if (col.key === "quantity" || col.key === "base_qty") { return ( @@ -469,6 +502,11 @@ export function BomTreeComponent({ ); } + if (col.key === "process_type" && value) { + const label = categoryLabels.process_type?.[String(value)] || String(value); + return {label}; + } + if (col.key === "loss_rate") { const num = Number(value); if (!num) return -; diff --git a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx index d36bfe6e..48c27cc9 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx @@ -43,6 +43,8 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve const [loading, setLoading] = useState(false); const [creating, setCreating] = useState(false); const [actionId, setActionId] = useState(null); + const [newVersionName, setNewVersionName] = useState(""); + const [showNewInput, setShowNewInput] = useState(false); useEffect(() => { if (open && bomId) loadVersions(); @@ -63,11 +65,26 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve const handleCreateVersion = async () => { if (!bomId) return; + const trimmed = newVersionName.trim(); + if (!trimmed) { + alert("버전명을 입력해주세요."); + return; + } setCreating(true); try { - const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable }); - if (res.data?.success) loadVersions(); - } catch (error) { + const res = await apiClient.post(`/bom/${bomId}/versions`, { + tableName, detailTable, versionName: trimmed, + }); + if (res.data?.success) { + setNewVersionName(""); + setShowNewInput(false); + loadVersions(); + } else { + alert(res.data?.message || "버전 생성 실패"); + } + } catch (error: any) { + const msg = error.response?.data?.message || "버전 생성 실패"; + alert(msg); console.error("[BomVersion] 생성 실패:", error); } finally { setCreating(false); @@ -230,15 +247,46 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve )}
+ {showNewInput && ( +
+ setNewVersionName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCreateVersion()} + placeholder="버전명 입력 (예: 2.0, B, 개선판)" + className="h-8 flex-1 rounded-md border px-3 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-10 sm:text-sm" + autoFocus + /> + + +
+ )} + - + {!showNewInput && ( + + )} + )} + {(componentConfig.leftPanel?.showDelete !== false) && ( + + )} +
+ + )} ); })} @@ -3429,6 +3465,10 @@ export const SplitPanelLayoutComponent: React.FC } // 🔧 일반 테이블 렌더링 (그룹화 없음) + const hasLeftTableActions = !isDesignMode && ( + (componentConfig.leftPanel?.showEdit !== false) || + (componentConfig.leftPanel?.showDelete !== false) + ); return (
@@ -3447,6 +3487,10 @@ export const SplitPanelLayoutComponent: React.FC {col.label} ))} + {hasLeftTableActions && ( + + )} @@ -3461,7 +3505,7 @@ export const SplitPanelLayoutComponent: React.FC handleLeftItemSelect(item)} - className={`hover:bg-accent cursor-pointer transition-colors ${ + className={`group hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" }`} > @@ -3479,6 +3523,34 @@ export const SplitPanelLayoutComponent: React.FC )} ))} + {hasLeftTableActions && ( + + )} ); })} From 708a0fbd1f50856614520458c707da0d56ae30ed Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 26 Feb 2026 20:55:15 +0900 Subject: [PATCH 06/15] Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node From bfc89501ba4e3182d30d8baf2eeb001750ccc498 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 07:33:54 +0900 Subject: [PATCH 07/15] feat: Enhance BOM and UI components with improved label handling and data mapping - Updated the BOM service to include additional fields in the BOM header retrieval, enhancing data richness. - Enhanced the EditModal to automatically map foreign key fields to dot notation, improving data handling and user experience. - Improved the rendering of labels in various components, allowing for customizable label positions and styles, enhancing UI flexibility. - Added new properties for label positioning and spacing in the V2 component styles, allowing for better layout control. - Enhanced the BomTreeComponent to support additional data mapping for entity joins, improving data accessibility and management. --- backend-node/src/services/bomService.ts | 5 +- frontend/components/screen/EditModal.tsx | 21 +++- .../EnhancedInteractiveScreenViewer.tsx | 53 ++++++--- .../screen/InteractiveScreenViewer.tsx | 53 +++++++-- .../screen/InteractiveScreenViewerDynamic.tsx | 62 +++++++++-- .../screen/panels/V2PropertiesPanel.tsx | 59 ++++++++-- frontend/components/v2/V2Date.tsx | 71 ++++++++---- frontend/components/v2/V2Input.tsx | 101 ++++++++++-------- frontend/components/v2/V2Select.tsx | 94 ++++++++++------ .../v2-bom-tree/BomDetailEditModal.tsx | 20 ++-- .../v2-bom-tree/BomTreeComponent.tsx | 13 +++ frontend/types/v2-core.ts | 2 + 12 files changed, 409 insertions(+), 145 deletions(-) diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index b5cff246..f1d6fd84 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -59,7 +59,10 @@ export async function getBomHeader(bomId: string, tableName?: string) { const table = safeTableName(tableName || "", "bom"); const sql = ` SELECT b.*, - i.item_name, i.item_number, i.division as item_type, i.unit + i.item_name, i.item_number, i.division as item_type, + COALESCE(b.unit, i.unit) as unit, + i.unit as item_unit, + i.division, i.size, i.material FROM ${table} b LEFT JOIN item_info i ON b.item_id = i.id WHERE b.id = $1 diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 8dad77db..41c51d85 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -274,7 +274,26 @@ export const EditModal: React.FC = ({ className }) => { }); // 편집 데이터로 폼 데이터 초기화 - setFormData(editData || {}); + // entity join 필드(xxx_yyy)를 dot notation(table.column)으로도 매핑 + const enriched = { ...(editData || {}) }; + if (editData) { + Object.keys(editData).forEach((key) => { + // item_id_item_name → item_info.item_name 패턴 변환 + const match = key.match(/^(.+?)_([a-z_]+)$/); + if (match && editData[key] != null) { + const [, fkCol, fieldName] = match; + // FK가 _id로 끝나면 참조 테이블명 추론 (item_id → item_info) + if (fkCol.endsWith("_id")) { + const refTable = fkCol.replace(/_id$/, "_info"); + const dotKey = `${refTable}.${fieldName}`; + if (!(dotKey in enriched)) { + enriched[dotKey] = editData[key]; + } + } + } + }); + } + setFormData(enriched); // originalData: changedData 계산(PATCH)에만 사용 // INSERT/UPDATE 판단에는 사용하지 않음 setOriginalData(isCreateMode ? {} : editData || {}); diff --git a/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx b/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx index f1acae0b..d0a99d91 100644 --- a/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx +++ b/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx @@ -245,23 +245,29 @@ export const EnhancedInteractiveScreenViewer: React.FC { if (hideLabel) return null; - const labelStyle = widget.style || {}; + const ls = widget.style || {}; const labelElement = (
+
+
+ {(componentConfig.leftPanel?.showEdit !== false) && ( + + )} + {(componentConfig.leftPanel?.showDelete !== false) && ( + + )} +
+
+ + + + + + + + + + + + + + + {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 5234a74d..1aede9de 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) => { @@ -824,6 +827,15 @@ export function BomTreeComponent({ 버전 )} +
); } diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 427f2da5..5a839620 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -20,6 +20,7 @@ import { Trash2, Settings, Move, + FileSpreadsheet, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; @@ -43,6 +44,7 @@ import { useSplitPanel } from "./SplitPanelContext"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { PanelInlineComponent } from "./types"; import { cn } from "@/lib/utils"; +import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal"; export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 props @@ -500,6 +502,7 @@ export const SplitPanelLayoutComponent: React.FC const [showAddModal, setShowAddModal] = useState(false); const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null); const [addModalFormData, setAddModalFormData] = useState>({}); + const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false); // 수정 모달 상태 const [showEditModal, setShowEditModal] = useState(false); @@ -3010,12 +3013,20 @@ export const SplitPanelLayoutComponent: React.FC {componentConfig.leftPanel?.title || "좌측 패널"} - {!isDesignMode && componentConfig.leftPanel?.showAdd && ( - - )} +
+ {!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && ( + + )} + {!isDesignMode && componentConfig.leftPanel?.showAdd && ( + + )} +
{componentConfig.leftPanel?.showSearch && ( @@ -5070,6 +5081,16 @@ export const SplitPanelLayoutComponent: React.FC
+ + {(componentConfig.leftPanel as any)?.showBomExcelUpload && ( + { + loadLeftData(); + }} + /> + )} ); }; From 4e997ae36b37a8ce3a35d6473de257a934e9c480 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 08:48:21 +0900 Subject: [PATCH 09/15] feat: Enhance V2Select component with automatic value normalization and update handling - Implemented automatic normalization of legacy plain text values to category codes, improving data consistency. - Added logic to handle comma-separated values, allowing for better processing of complex input formats. - Integrated automatic updates to the onChange handler when the normalized value differs from the original, ensuring accurate data saving. - Updated various select components to utilize the resolved value for consistent behavior across different selection types. --- frontend/components/v2/V2Select.tsx | 52 ++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index bd81c3ca..a7769227 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -820,6 +820,42 @@ export const V2Select = forwardRef( loadOptions(); }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); + // 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응) + const resolvedValue = useMemo(() => { + if (!value || options.length === 0) return value; + + const resolveOne = (v: string): string => { + if (options.some(o => o.value === v)) return v; + const trimmed = v.trim(); + const match = options.find(o => { + const cleanLabel = o.label.replace(/^[\s└]+/, '').trim(); + return cleanLabel === trimmed; + }); + return match ? match.value : v; + }; + + if (Array.isArray(value)) { + const resolved = value.map(resolveOne); + return resolved.every((v, i) => v === value[i]) ? value : resolved; + } + + // 콤마 구분 복합값 처리 (e.g., "구매품,판매품,CAT_xxx") + if (typeof value === "string" && value.includes(",")) { + const parts = value.split(","); + const resolved = parts.map(p => resolveOne(p.trim())); + const result = resolved.join(","); + return result === value ? value : result; + } + + return resolveOne(value); + }, [value, options]); + + // 정규화 결과가 원본과 다르면 onChange로 자동 업데이트 (저장 시 코드 변환) + useEffect(() => { + if (!onChange || options.length === 0 || !value || value === resolvedValue) return; + onChange(resolvedValue as string | string[]); + }, [resolvedValue]); // eslint-disable-line react-hooks/exhaustive-deps + // 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지 const autoFillTargets = useMemo(() => { if (source !== "entity" || !entityTable || !allComponents) return []; @@ -945,7 +981,7 @@ export const V2Select = forwardRef( return ( ( return ( handleChangeWithAutoFill(v)} disabled={isDisabled} /> @@ -972,7 +1008,7 @@ export const V2Select = forwardRef( return ( ( return ( ( return ( ( return ( handleChangeWithAutoFill(v)} disabled={isDisabled} /> @@ -1017,7 +1053,7 @@ export const V2Select = forwardRef( return ( ( return ( Date: Fri, 27 Feb 2026 11:01:22 +0900 Subject: [PATCH 10/15] refactor: Hide selected rows information in TableListComponent - Removed the display of selected rows count and the deselect button from the TableListComponent. - Updated the comment to indicate that the selected information is now hidden, improving code clarity and maintainability. --- .../lib/registry/DynamicComponentRenderer.tsx | 3 ++- .../v2-table-list/TableListComponent.tsx | 24 ++++++------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 72af2a34..88eaf946 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -356,9 +356,10 @@ export const DynamicComponentRenderer: React.FC = // 1. componentType이 "select-basic" 또는 "v2-select"인 경우 // 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등) const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode; + const isMultipleSelect = (component as any).componentConfig?.multiple; const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"]; const isNonDropdownMode = componentMode && nonDropdownModes.includes(componentMode); - const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode; + const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect; if ( (inputType === "category" || webType === "category") && diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 4170360d..717ea6ef 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2243,6 +2243,12 @@ export const TableListComponent: React.FC = ({ // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글) const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { e.stopPropagation(); + + // 현재 편집 중인 셀을 클릭한 경우 포커스 이동 방지 (select 드롭다운 등이 닫히는 것 방지) + if (editingCell?.rowIndex === rowIndex && editingCell?.colIndex === colIndex) { + return; + } + setFocusedCell({ rowIndex, colIndex }); tableContainerRef.current?.focus(); @@ -5462,23 +5468,7 @@ export const TableListComponent: React.FC = ({ )} - {/* 선택 정보 */} - {selectedRows.size > 0 && ( -
- - {selectedRows.size}개 선택됨 - - -
- )} + {/* 선택 정보 - 숨김 처리 */} {/* 🆕 통합 검색 패널 */} {(tableConfig.toolbar?.showSearch ?? false) && ( From 0f52c3adc28b7619ace1b68bdb9261ef1a9c0fef Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 27 Feb 2026 11:46:43 +0900 Subject: [PATCH 11/15] refactor: Improve V2Repeater integration and event handling - Updated the EditModal component to check for registered V2Repeater instances before saving detail data, enhancing the reliability of the repeater save process. - Simplified the V2Repeater component by removing unnecessary groupedData handling, ensuring it manages its own data effectively. - Enhanced the DynamicComponentRenderer to correctly handle V2Repeater's data management, improving overall component behavior. - Refactored button actions to wait for V2Repeater save completion only when active repeaters are present, optimizing performance and user experience. --- frontend/components/screen/EditModal.tsx | 114 +++++++++--------- frontend/components/v2/V2Repeater.tsx | 104 ++-------------- .../lib/registry/DynamicComponentRenderer.tsx | 6 +- frontend/lib/utils/buttonActions.ts | 93 +++++++------- 4 files changed, 118 insertions(+), 199 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 442c51cb..1f4d4dcc 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -1202,38 +1202,35 @@ 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); - }); + // V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만) + const hasRepeaterForInsert = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; + if (hasRepeaterForInsert) { + 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, + }, + }), + ); - 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); + await repeaterSavePromise; + } catch (repeaterError) { + console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + } } handleClose(); @@ -1332,38 +1329,35 @@ 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); - }); + // V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만) + const hasRepeaterForUpdate = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; + if (hasRepeaterForUpdate) { + 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, + }, + }), + ); - 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); + await repeaterSavePromise; + } catch (repeaterError) { + console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + } } // 리피터 저장 완료 후 메인 테이블 새로고침 diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index 8b769b56..b60617e6 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -50,9 +50,6 @@ export const V2Repeater: React.FC = ({ 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; @@ -214,21 +211,20 @@ export const V2Repeater: React.FC = ({ const isModalMode = config.renderMode === "modal"; // 전역 리피터 등록 - // 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블) + // tableName이 비어있어도 반드시 등록 (repeaterSave 이벤트 발행 가드에 필요) useEffect(() => { const targetTableName = config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + const registrationKey = targetTableName || "__v2_repeater_same_table__"; - if (targetTableName) { - if (!window.__v2RepeaterInstances) { - window.__v2RepeaterInstances = new Set(); - } - window.__v2RepeaterInstances.add(targetTableName); + if (!window.__v2RepeaterInstances) { + window.__v2RepeaterInstances = new Set(); } + window.__v2RepeaterInstances.add(registrationKey); return () => { - if (targetTableName && window.__v2RepeaterInstances) { - window.__v2RepeaterInstances.delete(targetTableName); + if (window.__v2RepeaterInstances) { + window.__v2RepeaterInstances.delete(registrationKey); } }; }, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]); @@ -968,90 +964,8 @@ export const V2Repeater: React.FC = ({ [], ); - // 모달에서 전달된 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]); + // V2Repeater는 자체 데이터 관리 (아이템 선택 모달, useCustomTable 로딩, DataReceiver)를 사용. + // EditModal의 groupedData는 메인 테이블 레코드이므로 V2Repeater에서는 사용하지 않음. // parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신 useEffect(() => { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 88eaf946..85532c36 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -546,10 +546,12 @@ export const DynamicComponentRenderer: React.FC = let currentValue; if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal" || - componentType === "selected-items-detail-input" || - componentType === "v2-repeater") { + componentType === "selected-items-detail-input") { // EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용 currentValue = props.groupedData || formData?.[fieldName] || []; + } else if (componentType === "v2-repeater") { + // V2Repeater는 자체 데이터 관리 (groupedData는 메인 테이블 레코드이므로 사용하지 않음) + currentValue = formData?.[fieldName] || []; } else { currentValue = formData?.[fieldName] || ""; } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 390404ce..7f8514ab 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1893,29 +1893,34 @@ export class ButtonActionExecutor { mainFormDataKeys: Object.keys(mainFormData), }); - // V2Repeater 저장 완료를 기다리기 위한 Promise - const repeaterSavePromise = new Promise((resolve) => { - const fallbackTimeout = setTimeout(resolve, 5000); - const handler = () => { - clearTimeout(fallbackTimeout); - window.removeEventListener("repeaterSaveComplete", handler); - resolve(); - }; - window.addEventListener("repeaterSaveComplete", handler); - }); + // V2Repeater가 등록된 경우에만 저장 완료를 기다림 + // @ts-ignore + const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: savedId, - tableName: context.tableName, - mainFormData, - masterRecordId: savedId, - }, - }), - ); + if (hasActiveRepeaters) { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); - await repeaterSavePromise; + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: savedId, + tableName: context.tableName, + mainFormData, + masterRecordId: savedId, + }, + }), + ); + + await repeaterSavePromise; + } // 테이블과 플로우 새로고침 (모달 닫기 전에 실행) context.onRefresh?.(); @@ -1951,29 +1956,33 @@ export class ButtonActionExecutor { formDataKeys: Object.keys(formData), }); - const repeaterSavePromise = new Promise((resolve) => { - const fallbackTimeout = setTimeout(resolve, 5000); - const handler = () => { - clearTimeout(fallbackTimeout); - window.removeEventListener("repeaterSaveComplete", handler); - resolve(); - }; - window.addEventListener("repeaterSaveComplete", handler); - }); + // @ts-ignore + const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: savedId, - tableName: context.tableName, - mainFormData: formData, - masterRecordId: savedId, - }, - }), - ); + if (hasActiveRepeaters) { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); - await repeaterSavePromise; - console.log("✅ [dispatchRepeaterSave] repeaterSave 완료"); + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: savedId, + tableName: context.tableName, + mainFormData: formData, + masterRecordId: savedId, + }, + }), + ); + + await repeaterSavePromise; + } } /** From d686c385e00b891fc84261aae4e58ae8b70a03e6 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 11:57:21 +0900 Subject: [PATCH 12/15] feat: Implement edit mode detection in SelectedItemsDetailInputComponent - Added logic to detect edit mode based on URL parameters and existing data IDs. - Enhanced value retrieval for form fields to prioritize original data in edit mode, ensuring accurate updates. - Removed redundant edit mode detection comments to streamline the code and improve clarity. --- .../SelectedItemsDetailInputComponent.tsx | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) 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 c2be4bb4..ff94b8dc 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -568,6 +568,12 @@ export const SelectedItemsDetailInputComponent: React.FC !!item.originalData?.id); + const isEditMode = urlEditMode || dataHasDbId; + // 부모 키 추출 (parentDataMapping에서) const parentKeys: Record = {}; @@ -581,16 +587,25 @@ export const SelectedItemsDetailInputComponent: React.FC { - // 1차: formData(sourceData)에서 찾기 - let value = getFieldValue(sourceData, mapping.sourceField); + let value: any; - // 2차: formData에 없으면 dataRegistry[sourceTable]에서 찾기 - // v2-split-panel-layout에서 좌측 항목 선택 시 dataRegistry에 저장한 데이터 활용 - if ((value === undefined || value === null) && mapping.sourceTable) { - const registryData = dataRegistry[mapping.sourceTable]; - if (registryData && registryData.length > 0) { - const registryItem = registryData[0].originalData || registryData[0]; - value = registryItem[mapping.sourceField]; + // 수정 모드: originalData의 targetField 값 우선 사용 + // 로드(editFilters)와 동일한 방식으로 FK 값을 가져와야 + // 백엔드에서 기존 레코드를 정확히 매칭하여 UPDATE 수행 가능 + if (isEditMode && items.length > 0 && items[0].originalData) { + value = items[0].originalData[mapping.targetField]; + } + + // 신규 모드 또는 originalData에 값 없으면 기존 로직 + if (value === undefined || value === null) { + value = getFieldValue(sourceData, mapping.sourceField); + + if ((value === undefined || value === null) && mapping.sourceTable) { + const registryData = dataRegistry[mapping.sourceTable]; + if (registryData && registryData.length > 0) { + const registryItem = registryData[0].originalData || registryData[0]; + value = registryItem[mapping.sourceField]; + } } } @@ -646,15 +661,6 @@ export const SelectedItemsDetailInputComponent: React.FC !!item.originalData?.id); - const isEditMode = urlEditMode || dataHasDbId; - console.log("[SelectedItemsDetailInput] 수정 모드 감지:", { urlEditMode, dataHasDbId, From c1f7f27005952d0f950a872fb005dd7be7c7fbb1 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 27 Feb 2026 12:06:49 +0900 Subject: [PATCH 13/15] fix: Improve option filtering in V2Select component - Updated the option filtering logic to handle null and undefined values, preventing potential crashes when cmdk encounters these values. - Introduced a safeOptions variable to ensure that only valid options are processed in the dropdown and command list. - Enhanced the setOptions function to sanitize fetched options, ensuring that only valid values are set, improving overall stability and user experience. --- frontend/components/v2/V2Select.tsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index f0021eeb..bdc1ae24 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -80,7 +80,7 @@ const DropdownSelect = forwardRef {options - .filter((option) => option.value !== "") + .filter((option) => option.value != null && option.value !== "") .map((option) => ( {option.label} @@ -112,6 +112,12 @@ const DropdownSelect = forwardRef + options.filter((o) => o.value != null && o.value !== ""), + [options] + ); + const selectedValues = useMemo(() => { if (!value) return []; return Array.isArray(value) ? value : [value]; @@ -119,9 +125,9 @@ const DropdownSelect = forwardRef { return selectedValues - .map((v) => options.find((o) => o.value === v)?.label) + .map((v) => safeOptions.find((o) => o.value === v)?.label) .filter(Boolean) as string[]; - }, [selectedValues, options]); + }, [selectedValues, safeOptions]); const handleSelect = useCallback((selectedValue: string) => { if (multiple) { @@ -191,7 +197,7 @@ const DropdownSelect = forwardRef { if (!search) return 1; - const option = options.find((o) => o.value === itemValue); + const option = safeOptions.find((o) => o.value === itemValue); const label = (option?.label || option?.value || "").toLowerCase(); if (label.includes(search.toLowerCase())) return 1; return 0; @@ -201,7 +207,7 @@ const DropdownSelect = forwardRef 검색 결과가 없습니다. - {options.map((option) => { + {safeOptions.map((option) => { const displayLabel = option.label || option.value || "(빈 값)"; return ( ( } } - setOptions(fetchedOptions); + // null/undefined value 필터링 (cmdk 크래시 방지) + const sanitized = fetchedOptions.filter( + (o) => o.value != null && String(o.value) !== "" + ).map((o) => ({ ...o, value: String(o.value), label: o.label || String(o.value) })); + setOptions(sanitized); setOptionsLoaded(true); } catch (error) { console.error("옵션 로딩 실패:", error); From 8bfc2ba4f57f2d7dfb7eb212d3351ea8c1986b72 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 27 Feb 2026 13:00:22 +0900 Subject: [PATCH 14/15] feat: Enhance dynamic form service to handle VIEW tables - Introduced a new method `resolveBaseTable` to determine the original table name for VIEWs, allowing for seamless data operations. - Updated existing methods (`saveFormData`, `updateFormDataPartial`, `updateFormData`, and `deleteFormData`) to utilize `resolveBaseTable`, ensuring that operations are performed on the correct base table. - Improved logging to provide clearer insights into the operations being performed, including handling of original table names when dealing with VIEWs. --- .../src/services/dynamicFormService.ts | 71 ++++++++++++++++--- .../components/common/ExcelUploadModal.tsx | 45 ++++++++---- 2 files changed, 95 insertions(+), 21 deletions(-) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index e1242afd..7383e02b 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -210,19 +210,62 @@ export class DynamicFormService { } } + /** + * VIEW인 경우 원본(base) 테이블명을 반환, 일반 테이블이면 그대로 반환 + */ + async resolveBaseTable(tableName: string): Promise { + try { + const result = await query<{ table_type: string }>( + `SELECT table_type FROM information_schema.tables + WHERE table_name = $1 AND table_schema = 'public'`, + [tableName] + ); + + if (result.length === 0 || result[0].table_type !== 'VIEW') { + return tableName; + } + + // VIEW의 FROM 절에서 첫 번째 테이블을 추출 + const viewDef = await query<{ view_definition: string }>( + `SELECT view_definition FROM information_schema.views + WHERE table_name = $1 AND table_schema = 'public'`, + [tableName] + ); + + if (viewDef.length > 0) { + const definition = viewDef[0].view_definition; + // PostgreSQL은 뷰 정의를 "FROM (테이블명 별칭 LEFT JOIN ...)" 형태로 저장 + const fromMatch = definition.match(/FROM\s+\(?(?:public\.)?(\w+)\s/i); + if (fromMatch) { + const baseTable = fromMatch[1]; + console.log(`🔄 VIEW ${tableName} → 원본 테이블 ${baseTable} 으로 전환`); + return baseTable; + } + } + + return tableName; + } catch (error) { + console.error(`❌ VIEW 원본 테이블 조회 실패:`, error); + return tableName; + } + } + /** * 폼 데이터 저장 (실제 테이블에 직접 저장) */ async saveFormData( screenId: number, - tableName: string, + tableNameInput: string, data: Record, ipAddress?: string ): Promise { + // VIEW인 경우 원본 테이블로 전환 + const tableName = await this.resolveBaseTable(tableNameInput); try { console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", { screenId, tableName, + originalTable: tableNameInput !== tableName ? tableNameInput : undefined, data, }); @@ -813,14 +856,17 @@ export class DynamicFormService { */ async updateFormDataPartial( id: string | number, // 🔧 UUID 문자열도 지원 - tableName: string, + tableNameInput: string, originalData: Record, newData: Record ): Promise { + // VIEW인 경우 원본 테이블로 전환 + const tableName = await this.resolveBaseTable(tableNameInput); try { console.log("🔄 서비스: 부분 업데이트 시작:", { id, tableName, + originalTable: tableNameInput !== tableName ? tableNameInput : undefined, originalData, newData, }); @@ -1008,13 +1054,16 @@ export class DynamicFormService { */ async updateFormData( id: string | number, - tableName: string, + tableNameInput: string, data: Record ): Promise { + // VIEW인 경우 원본 테이블로 전환 + const tableName = await this.resolveBaseTable(tableNameInput); try { console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", { id, tableName, + originalTable: tableNameInput !== tableName ? tableNameInput : undefined, data, }); @@ -1212,9 +1261,13 @@ export class DynamicFormService { screenId?: number ): Promise { try { + // VIEW인 경우 원본 테이블로 전환 (VIEW에는 기본키가 없으므로) + const actualTable = await this.resolveBaseTable(tableName); + console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { id, - tableName, + tableName: actualTable, + originalTable: tableName !== actualTable ? tableName : undefined, }); // 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회 @@ -1232,15 +1285,15 @@ export class DynamicFormService { `; console.log("🔍 기본키 조회 SQL:", primaryKeyQuery); - console.log("🔍 테이블명:", tableName); + console.log("🔍 테이블명:", actualTable); const primaryKeyResult = await query<{ column_name: string; data_type: string; - }>(primaryKeyQuery, [tableName]); + }>(primaryKeyQuery, [actualTable]); if (!primaryKeyResult || primaryKeyResult.length === 0) { - throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); + throw new Error(`테이블 ${actualTable}의 기본키를 찾을 수 없습니다.`); } const primaryKeyInfo = primaryKeyResult[0]; @@ -1272,7 +1325,7 @@ export class DynamicFormService { // 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성 const deleteQuery = ` - DELETE FROM ${tableName} + DELETE FROM ${actualTable} WHERE ${primaryKeyColumn} = $1${typeCastSuffix} RETURNING * `; @@ -1292,7 +1345,7 @@ export class DynamicFormService { // 삭제된 행이 없으면 레코드를 찾을 수 없는 것 if (!result || !Array.isArray(result) || result.length === 0) { - throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`); + throw new Error(`테이블 ${actualTable}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`); } console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 81b5ed61..f3c2ff2d 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -903,7 +903,8 @@ export const ExcelUploadModal: React.FC = ({ } } - for (const row of filteredData) { + for (let rowIdx = 0; rowIdx < filteredData.length; rowIdx++) { + const row = filteredData[rowIdx]; try { let dataToSave = { ...row }; let shouldSkip = false; @@ -925,15 +926,16 @@ export const ExcelUploadModal: React.FC = ({ if (existingDataMap.has(key)) { existingRow = existingDataMap.get(key); - // 중복 발견 - 전역 설정에 따라 처리 if (duplicateAction === "skip") { shouldSkip = true; skipCount++; - console.log(`⏭️ 중복으로 건너뛰기: ${key}`); + console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`); } else { shouldUpdate = true; - console.log(`🔄 중복으로 덮어쓰기: ${key}`); + console.log(`🔄 [행 ${rowIdx + 1}] 중복으로 덮어쓰기: ${key}`); } + } else { + console.log(`✅ [행 ${rowIdx + 1}] 중복 아님 (신규 데이터): ${key}`); } } @@ -943,7 +945,7 @@ export const ExcelUploadModal: React.FC = ({ } // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용 - if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { + if (hasNumbering && numberingInfo && (uploadMode === "insert" || uploadMode === "upsert") && !shouldUpdate) { const existingValue = dataToSave[numberingInfo.columnName]; const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== ""; @@ -968,24 +970,34 @@ export const ExcelUploadModal: React.FC = ({ tableName, data: dataToSave, }; + console.log(`📝 [행 ${rowIdx + 1}] 덮어쓰기 시도: id=${existingRow.id}`, dataToSave); const result = await DynamicFormApi.updateFormData(existingRow.id, formData); if (result.success) { overwriteCount++; successCount++; } else { + console.error(`❌ [행 ${rowIdx + 1}] 덮어쓰기 실패:`, result.message); failCount++; } - } else if (uploadMode === "insert") { - // 신규 등록 + } else if (uploadMode === "insert" || uploadMode === "upsert") { + // 신규 등록 (insert, upsert 모드) const formData = { screenId: 0, tableName, data: dataToSave }; + console.log(`📝 [행 ${rowIdx + 1}] 신규 등록 시도 (mode: ${uploadMode}):`, dataToSave); const result = await DynamicFormApi.saveFormData(formData); if (result.success) { successCount++; + console.log(`✅ [행 ${rowIdx + 1}] 신규 등록 성공`); } else { + console.error(`❌ [행 ${rowIdx + 1}] 신규 등록 실패:`, result.message); failCount++; } + } else if (uploadMode === "update") { + // update 모드에서 기존 데이터가 없는 행은 건너뛰기 + console.log(`⏭️ [행 ${rowIdx + 1}] update 모드: 기존 데이터 없음, 건너뛰기`); + skipCount++; } - } catch (error) { + } catch (error: any) { + console.error(`❌ [행 ${rowIdx + 1}] 업로드 처리 오류:`, error?.response?.data || error?.message || error); failCount++; } } @@ -1008,8 +1020,9 @@ export const ExcelUploadModal: React.FC = ({ } } + console.log(`📊 엑셀 업로드 결과 요약: 성공=${successCount}, 건너뛰기=${skipCount}, 덮어쓰기=${overwriteCount}, 실패=${failCount}`); + if (successCount > 0 || skipCount > 0) { - // 상세 결과 메시지 생성 let message = ""; if (successCount > 0) { message += `${successCount}개 행 업로드`; @@ -1022,15 +1035,23 @@ export const ExcelUploadModal: React.FC = ({ message += `중복 건너뛰기 ${skipCount}개`; } if (failCount > 0) { - message += ` (실패: ${failCount}개)`; + message += `, 실패 ${failCount}개`; } - toast.success(message); + if (failCount > 0 && successCount === 0) { + toast.warning(message); + } else { + toast.success(message); + } // 매핑 템플릿 저장 await saveMappingTemplateInternal(); - onSuccess?.(); + if (successCount > 0 || overwriteCount > 0) { + onSuccess?.(); + } + } else if (failCount > 0) { + toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`); } else { toast.error("업로드에 실패했습니다."); } From 649bd77bbb8c10709d88dfb5740d77dac0eb3b36 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 13:09:20 +0900 Subject: [PATCH 15/15] feat: Enhance dynamic form and BOM item editor functionality - Added support for updating the `updated_date` field in the DynamicFormService, ensuring accurate timestamp management. - Refactored the BomItemEditorComponent to improve data handling by filtering valid fields before saving, enhancing data integrity. - Introduced a mechanism to track existing item IDs to prevent duplicates during item addition, improving user experience and data consistency. - Streamlined the save process in ButtonActionExecutor by reorganizing the event handling logic, ensuring better integration with EditModal components. --- .../src/services/dynamicFormService.ts | 3 + bom-save-console-logs.txt | 271 ++++++++++++++++++ .../BomItemEditorComponent.tsx | 52 ++-- frontend/lib/utils/buttonActions.ts | 42 +-- 4 files changed, 315 insertions(+), 53 deletions(-) create mode 100644 bom-save-console-logs.txt diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index e1242afd..4c24e206 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1033,6 +1033,9 @@ export class DynamicFormService { if (tableColumns.includes("updated_at")) { dataToUpdate.updated_at = new Date(); } + if (tableColumns.includes("updated_date")) { + dataToUpdate.updated_date = new Date(); + } if (tableColumns.includes("regdate") && !dataToUpdate.regdate) { dataToUpdate.regdate = new Date(); } diff --git a/bom-save-console-logs.txt b/bom-save-console-logs.txt new file mode 100644 index 00000000..f962f536 --- /dev/null +++ b/bom-save-console-logs.txt @@ -0,0 +1,271 @@ +[info] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[warning] Image with src "/images/vexplor.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio. +[log] 첫 번째 접근 가능한 메뉴로 이동: /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[log] 📦 메인 테이블 데이터 자동 로드: company_mng {company_code: COMPANY_7, company_name: 탑씰 테스트, writer: wace, regdate: 2026-02-27T09:28:35.342Z, status: active} +[log] 📦 메인 테이블 데이터 자동 로드: company_mng {company_code: COMPANY_7, company_name: 탑씰 테스트, writer: wace, regdate: 2026-02-27T09:28:35.342Z, status: active} +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[error] Failed to load resource: the server responded with a status of 404 (Not Found) +[error] Failed to load resource: the server responded with a status of 404 (Not Found) +[error] Failed to load resource: the server responded with a status of 404 (Not Found) +[error] Failed to load resource: the server responded with a status of 404 (Not Found) +[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 1030135068124796000, error: 404} +[error] Failed to load resource: the server responded with a status of 404 (Not Found) +[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 666667496384701400, error: 404} +[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 88591267128165600, error: 404} +[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 1030135068124796000, error: 404} +[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 666667496384701400, error: 404} +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[error] Failed to load resource: the server responded with a status of 404 (Not Found) +[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 88591267128165600, error: 404} +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138 +[info] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 0 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 0 leftGroupSumConfig: null +[log] 📦 [SplitPanelLayout] Context에 분할 패널 등록: {splitPanelId: split-panel-comp_split_panel, panelInfo: Object} +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object] +[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object] +[log] 📦 [SplitPanelLayout] Context에서 분할 패널 해제: split-panel-comp_split_panel +[log] 📦 [SplitPanelLayout] Context에 분할 패널 등록: {splitPanelId: split-panel-comp_split_panel, panelInfo: Object} +[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object] +[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object] +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] ✅ 좌측 컬럼 라벨 로드: {id: ID, created_date: 생성일시, updated_date: 수정일시, writer: 작성자, company_code: 회사코드} +[log] ✅ 좌측 컬럼 라벨 로드: {id: ID, created_date: 생성일시, updated_date: 수정일시, writer: 작성자, company_code: 회사코드} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] ✅ 좌측 카테고리 매핑 로드 [status]: {CAT_MM3XFDT6_YULY: Object, CAT_MM3XFA7B_ZFD6: Object} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] ✅ 좌측 카테고리 매핑 로드 [status]: {CAT_MM3XFDT6_YULY: Object, CAT_MM3XFA7B_ZFD6: Object} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label] +[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label] +[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label] +[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label] +[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] ✅ 분할 패널 좌측 선택: bom {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7} +[log] 🔴 [ButtonPrimary] 저장 시 formData 디버그: {propsFormDataKeys: Array(70), screenContextFormDataKeys: Array(0), effectiveFormDataKeys: Array(70), process_code: undefined, equipment_code: undefined} +[log] [BomTree] openEditModal 가로채기 - editData 보정 {oldVersion: 1.0, newVersion: 1.0, oldCurrentVersionId: de575ae5-266c-42f0-be49-bcc65de89ebd, newCurrentVersionId: de575ae5-266c-42f0-be49-bcc65de89ebd} +[log] 🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침 +[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object] +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] [EditModal] 모달 열림: {mode: UPDATE (수정), hasEditData: true, editDataId: 64617576-fec9-4caa-8e72-653f9e83ba45, isCreateMode: false} +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용 +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] [EditModal] 화면 데이터 로드 완료, 조건부 레이어 로드 시작: 4154 +[log] [EditModal] loadConditionalLayersAndZones 호출됨: 4154 +[log] [EditModal] API 호출 시작: getScreenLayers, getScreenZones +[log] [EditModal] API 응답: {layers: 1, zones: 0} +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label] +[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7} +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [그룹합산] leftGroupSumConfig: null +[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환 +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0} +[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false +[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 +[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168 \ No newline at end of file diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index 75c1909b..fcb7b710 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -812,7 +812,7 @@ export function BomItemEditorComponent({ : null; if (node._isNew) { - const payload: Record = { + const raw: Record = { ...node.data, [fkColumn]: bomId, [parentKeyColumn]: realParentId, @@ -821,10 +821,16 @@ export function BomItemEditorComponent({ company_code: companyCode || undefined, version_id: saveVersionId || undefined, }; - delete payload.id; - delete payload.tempId; - delete payload._isNew; - delete payload._isDeleted; + // bom_detail에 유효한 필드만 남기기 (item_info 조인 필드 제거) + const payload: Record = {}; + const validKeys = new Set([ + fkColumn, parentKeyColumn, "seq_no", "level", "child_item_id", + "quantity", "unit", "loss_rate", "remark", "process_type", + "base_qty", "revision", "version_id", "company_code", "writer", + ]); + Object.keys(raw).forEach((k) => { + if (validKeys.has(k)) payload[k] = raw[k]; + }); const resp = await apiClient.post( `/table-management/tables/${mainTableName}/add`, @@ -835,17 +841,14 @@ export function BomItemEditorComponent({ savedCount++; } else if (node.id) { const updatedData: Record = { - ...node.data, id: node.id, + [fkColumn]: bomId, [parentKeyColumn]: realParentId, seq_no: String(seqNo), level: String(level), }; - delete updatedData.tempId; - delete updatedData._isNew; - delete updatedData._isDeleted; - Object.keys(updatedData).forEach((k) => { - if (k.startsWith(`${sourceFk}_`)) delete updatedData[k]; + ["quantity", "unit", "loss_rate", "remark", "process_type", "base_qty", "revision", "child_item_id", "version_id", "company_code"].forEach((k) => { + if (node.data[k] !== undefined) updatedData[k] = node.data[k]; }); await apiClient.put( @@ -934,6 +937,20 @@ export function BomItemEditorComponent({ setItemSearchOpen(true); }, []); + // 이미 추가된 품목 ID 목록 (중복 방지용) + const existingItemIds = useMemo(() => { + const ids = new Set(); + const collect = (nodes: BomItemNode[]) => { + for (const n of nodes) { + const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"]; + if (fk) ids.add(fk); + collect(n.children); + } + }; + collect(treeData); + return ids; + }, [treeData, cfg]); + // 루트 품목 추가 시작 const handleAddRoot = useCallback(() => { setAddTargetParentId(null); @@ -1353,18 +1370,7 @@ export function BomItemEditorComponent({ onClose={() => setItemSearchOpen(false)} onSelect={handleItemSelect} companyCode={companyCode} - existingItemIds={useMemo(() => { - const ids = new Set(); - const collect = (nodes: BomItemNode[]) => { - for (const n of nodes) { - const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"]; - if (fk) ids.add(fk); - collect(n.children); - } - }; - collect(treeData); - return ids; - }, [treeData, cfg])} + existingItemIds={existingItemIds} /> ); diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 7f8514ab..054b257f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -558,31 +558,7 @@ export class ButtonActionExecutor { return false; } - // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 - // EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림 - if (onSave) { - try { - await onSave(); - return true; - } catch (error) { - console.error("❌ [handleSave] onSave 콜백 실행 오류:", error); - throw error; - } - } - - console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행"); - - // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) - // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 - // skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음 - - // 🔧 디버그: beforeFormSave 이벤트 전 formData 확인 - console.log("🔍 [handleSave] beforeFormSave 이벤트 전:", { - keys: Object.keys(context.formData || {}), - hasCompanyImage: "company_image" in (context.formData || {}), - companyImageValue: context.formData?.company_image, - }); - + // beforeFormSave 이벤트 발송 (BomItemEditor 등 서브 컴포넌트의 저장 처리) const beforeSaveEventDetail = { formData: context.formData, skipDefaultSave: false, @@ -596,22 +572,28 @@ export class ButtonActionExecutor { }), ); - // 비동기 핸들러가 등록한 Promise들 대기 + 동기 핸들러를 위한 최소 대기 if (beforeSaveEventDetail.pendingPromises.length > 0) { - console.log( - `[handleSave] 비동기 beforeFormSave 핸들러 ${beforeSaveEventDetail.pendingPromises.length}건 대기 중...`, - ); await Promise.all(beforeSaveEventDetail.pendingPromises); } else { await new Promise((resolve) => setTimeout(resolve, 100)); } - // 검증 실패 시 저장 중단 if (beforeSaveEventDetail.validationFailed) { console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors); return false; } + // EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 + if (onSave) { + try { + await onSave(); + return true; + } catch (error) { + console.error("❌ [handleSave] onSave 콜백 실행 오류:", error); + throw error; + } + } + // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 // 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리) // EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림