From 7a97603106055f377f976384b850eaf7903c61bf Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 25 Feb 2026 17:03:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(pop-card-list):=203=EC=84=B9=EC=85=98?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20+=20=ED=8F=AC=EC=9E=A5=202=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EA=B3=84=EC=82=B0=EA=B8=B0=20+=20=EC=84=A4?= =?UTF-8?q?=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 2/2] =?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", }, };