From 7a97603106055f377f976384b850eaf7903c61bf Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 25 Feb 2026 17:03:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-card-list):=203=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=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; }