feat(pop-card-list): 3섹션 분리 + 포장 2단계 계산기 + 설정 패널 개편

- 입력 필드/포장등록/담기 버튼 독립 ON/OFF 분리
- NumberInputModal을 4단계 상태 머신으로 재작성
  (수량 -> 포장 수 -> 개당 수량 -> summary)
- 포장 단위 커스텀 지원 (기본 6종 + 디자이너 추가)
- 본문 필드에 계산식 통합 (3-드롭다운 수식 빌더)
- 입력 필드: limitColumn(동적 상한), saveTable/saveColumn(저장 대상)
- 저장 대상 테이블 선택을 TableCombobox로 교체 (검색 가능)
- 다중 정렬 지원 + 하위 호환 (sorts.map 에러 수정)
- GroupedColumnSelect 항상 테이블명 헤더 표시
- 반응형 표시 우선순위 (required/shrink/hidden) 설정
- PackageEntry/CartItem 타입 확장, CardPackageConfig 신규

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim 2026-02-25 17:03:47 +09:00
parent 8cfd4024e1
commit 7a97603106
10 changed files with 2173 additions and 1469 deletions

3
.gitignore vendored
View File

@ -292,4 +292,5 @@ uploads/
claude.md
# 개인 작업 문서 (popdocs)
popdocs/
popdocs/
.cursor/rules/popdocs-safety.mdc

733
PLAN.MD
View File

@ -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 && (
<div>
<Label className="text-xs">그룹핑 (X축)</Label>
<Popover open={groupByOpen} onOpenChange={setGroupByOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={groupByOpen}
disabled={loadingCols}
className="h-8 w-full justify-between text-xs"
>
{dataSource.aggregation.groupBy?.length
? dataSource.aggregation.groupBy.join(", ")
: "없음 (단일 값)"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
컬럼을 찾을 수 없습니다.
</CommandEmpty>
<CommandGroup>
{columns.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
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"
>
<Check
className={cn(
"mr-2 h-3 w-3",
dataSource.aggregation?.groupBy?.includes(col.name)
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-medium">{col.name}</span>
<span className="ml-1 text-muted-foreground">({col.type})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="mt-0.5 text-[10px] text-muted-foreground">
차트에서 X축 카테고리로 사용됩니다
</p>
</div>
)}
```
**필요한 state 추가** (DataSourceEditor 내부, 기존 state 옆):
```tsx
const [groupByOpen, setGroupByOpen] = useState(false);
```
#### 변경 A-2: 차트 타입 설정에 xAxisColumn/yAxisColumn 추가 (라인 1187~1212 부근)
**추가할 위치**: `{item.subType === "chart" && (` 블록 내부, 차트 유형 Select 다음
**추가할 코드** (약 30줄):
```tsx
{/* X축 컬럼 */}
<div>
<Label className="text-xs">X축 컬럼</Label>
<Input
value={item.chartConfig?.xAxisColumn ?? ""}
onChange={(e) =>
onUpdate({
...item,
chartConfig: {
...item.chartConfig,
chartType: item.chartConfig?.chartType ?? "bar",
xAxisColumn: e.target.value || undefined,
},
})
}
placeholder="groupBy 컬럼명 (비우면 자동)"
className="h-8 text-xs"
/>
<p className="mt-0.5 text-[10px] text-muted-foreground">
그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용
</p>
</div>
```
#### 변경 A-3: 통계 카드 타입에 카테고리 설정 UI 추가 (라인 1272 부근, 게이지 설정 다음)
**추가할 위치**: `{item.subType === "gauge" && (` 블록 다음에 새 블록 추가
**추가할 코드** (약 100줄): `StatCategoryEditor` 인라인 블록
```tsx
{item.subType === "stat-card" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">카테고리 설정</Label>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={() => {
const currentCats = item.statConfig?.categories ?? [];
onUpdate({
...item,
statConfig: {
...item.statConfig,
categories: [
...currentCats,
{
label: `카테고리 ${currentCats.length + 1}`,
filter: { column: "", operator: "=", value: "" },
},
],
},
});
}}
>
<Plus className="mr-1 h-3 w-3" />
카테고리 추가
</Button>
</div>
{(item.statConfig?.categories ?? []).map((cat, catIdx) => (
<div key={catIdx} className="space-y-1 rounded border p-2">
<div className="flex items-center gap-1">
<Input
value={cat.label}
onChange={(e) => {
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"
/>
<Input
value={cat.color ?? ""}
onChange={(e) => {
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"
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => {
const newCats = (item.statConfig?.categories ?? []).filter(
(_, i) => i !== catIdx
);
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 필터 조건: 컬럼 / 연산자 / 값 */}
<div className="flex items-center gap-1 text-[10px]">
<Input
value={cat.filter.column}
onChange={(e) => {
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]"
/>
<Select
value={cat.filter.operator}
onValueChange={(val) => {
const newCats = [...(item.statConfig?.categories ?? [])];
newCats[catIdx] = {
...cat,
filter: { ...cat.filter, operator: val as FilterOperator },
};
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
>
<SelectTrigger className="h-6 w-16 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=" className="text-xs">= 같음</SelectItem>
<SelectItem value="!=" className="text-xs">!= 다름</SelectItem>
<SelectItem value="like" className="text-xs">LIKE</SelectItem>
</SelectContent>
</Select>
<Input
value={String(cat.filter.value ?? "")}
onChange={(e) => {
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]"
/>
</div>
</div>
))}
{(item.statConfig?.categories ?? []).length === 0 && (
<p className="text-[10px] text-muted-foreground">
카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다
</p>
)}
</div>
)}
```
---
### 파일 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 (
<ChartItemComponent
item={item}
rows={itemData.rows}
containerWidth={containerWidth}
/>
);
```
**변경 코드**:
```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 (
<ChartItemComponent
item={chartItem}
rows={itemData.rows}
containerWidth={containerWidth}
/>
);
}
```
#### 변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297)
**현재 코드** (버그):
```tsx
case "stat-card": {
const categoryData: Record<string, number> = {};
if (item.statConfig?.categories) {
for (const cat of item.statConfig.categories) {
categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값
}
}
return (
<StatCardComponent item={item} categoryData={categoryData} />
);
**현재 코드** (라인 367~372):
```typescript
export interface CardFieldBinding {
id: string;
columnName: string;
label: string;
textColor?: string;
}
```
**변경 코드**:
```tsx
case "stat-card": {
const categoryData: Record<string, number> = {};
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 (
<StatCardComponent item={item} categoryData={categoryData} />
);
```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. 키패드 모달에서 포장등록 버튼이 숨겨지는지 확인
---
## 이전 완료 계획 (아카이브)
<details>
<summary>pop-dashboard 4가지 아이템 모드 완성 (완료)</summary>
- [x] groupBy UI 추가
- [x] xAxisColumn 입력 UI 추가
- [x] 통계카드 카테고리 설정 UI 추가
- [x] 차트 xAxisColumn 자동 보정 로직
- [x] 통계카드 카테고리별 필터 적용
- [x] SQL 빌더 방어 로직
- [x] refreshInterval 최소값 강제
</details>
<details>
<summary>POP 뷰어 스크롤 수정 (완료)</summary>
- [x] 라인 185: overflow-hidden 제거
- [x] 라인 266: overflow-auto 공통 적용
- [x] 라인 275: 일반 모드 min-h-full 추가
- [x] 린트 검사 통과
- [x] overflow-hidden 제거
- [x] overflow-auto 공통 적용
- [x] 일반 모드 min-h-full 추가
</details>
@ -521,28 +333,5 @@ ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가
- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체
- [x] 린트 검사 통과
</details>
<details>
<summary>V2/V2 컴포넌트 설정 스키마 정비 (완료)</summary>
- [x] 레거시 컴포넌트 스키마 제거
- [x] V2 컴포넌트 overrides 스키마 정의 (16개)
- [x] V2 컴포넌트 overrides 스키마 정의 (9개)
- [x] componentConfig.ts 한 파일에서 통합 관리
</details>
<details>
<summary>화면 복제 기능 개선 (진행 중)</summary>
- [완료] DB 구조 개편 (menu_objid 의존성 제거)
- [완료] 복제 옵션 정리
- [완료] 화면 간 연결 복제 버그 수정
- [대기] 화면 간 연결 복제 테스트
- [대기] 제어관리 복제 테스트
- [대기] 추가 옵션 복제 테스트
</details>

View File

@ -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 반응형 표시 런타임 적용 | [ ] 대기 |
---

View File

@ -515,3 +515,4 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
);
}

View File

@ -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<string | undefined>(undefined);
const [step, setStep] = useState<InputStep>("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<PackageEntry[]>([]);
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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
@ -86,112 +228,199 @@ export function NumberInputModal({
<DialogPrimitive.Content
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] w-full max-w-[95vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden border shadow-lg duration-200 sm:max-w-[360px] sm:rounded-lg"
>
{/* 파란 헤더 */}
{/* 헤더 */}
<div className="flex items-center justify-between bg-blue-500 px-4 py-3">
<span className="rounded-full bg-blue-400/50 px-3 py-1 text-sm font-medium text-white">
{maxValue.toLocaleString()} {unit}
</span>
<button
type="button"
onClick={() => setIsPackageModalOpen(true)}
className="flex items-center gap-1 rounded-full bg-white/20 px-3 py-1.5 text-sm font-medium text-white hover:bg-white/30 active:bg-white/40"
>
{packageUnitEmoji} {packageUnitLabel ? `${packageUnitLabel}` : "포장등록"}
</button>
<div className="flex items-center gap-2">
{isBackVisible && (
<button
type="button"
onClick={handleBack}
className="flex items-center justify-center rounded-full bg-white/20 p-1.5 text-white hover:bg-white/30 active:bg-white/40"
>
<ArrowLeft className="h-4 w-4" />
</button>
)}
<span className="rounded-full bg-blue-400/50 px-3 py-1 text-sm font-medium text-white">
{headerLabel}
</span>
</div>
{isPackageEnabled && step === "quantity" && (
<button
type="button"
onClick={() => setIsPackageModalOpen(true)}
className="flex items-center gap-1 rounded-full bg-white/20 px-3 py-1.5 text-sm font-medium text-white hover:bg-white/30 active:bg-white/40"
>
</button>
)}
</div>
<div className="space-y-3 p-4">
{/* 숫자 표시 영역 */}
<div className="flex min-h-[72px] items-center justify-end rounded-xl border-2 border-gray-200 bg-gray-50 px-4 py-4">
{displayText ? (
<span className="text-4xl font-bold tracking-tight text-gray-900">
{displayText}
</span>
) : (
<span className="text-2xl text-gray-300">0</span>
)}
</div>
{/* summary 단계: 포장 내역 리스트 */}
{step === "summary" ? (
<div className="space-y-3">
{/* 안내 메시지 - 마지막 등록 결과 */}
{showSummary && entries.length > 0 && (
<div className="rounded-lg bg-blue-50 px-3 py-2 text-center text-sm font-medium text-blue-700">
{(() => {
const last = entries[entries.length - 1];
return `${last.packageCount}${last.unitLabel} x ${last.quantityPerUnit}${unit} = ${last.totalQuantity.toLocaleString()}${unit}`;
})()}
</div>
)}
{/* 안내 텍스트 */}
<p className="text-muted-foreground text-center text-sm">
</p>
{/* 포장 내역 리스트 */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-gray-500"> </p>
{entries.map((entry, idx) => (
<div
key={idx}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
<span className="text-sm text-gray-700">
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}{unit} = {entry.totalQuantity.toLocaleString()}{unit}
</span>
<button
type="button"
onClick={() => handleRemoveEntry(idx)}
className="rounded-full p-1 text-gray-400 hover:bg-red-50 hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
{/* 키패드 4x4 */}
<div className="grid grid-cols-4 gap-2">
{/* 1행: 7 8 9 ← (주황) */}
{["7", "8", "9"].map((n) => (
<button
key={n}
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick(n)}
>
{n}
</button>
))}
<button
type="button"
className="flex h-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600 active:bg-amber-200"
onClick={handleBackspace}
>
<Delete className="h-5 w-5" />
</button>
{/* 합계 */}
<div className="flex items-center justify-between rounded-lg bg-green-50 px-3 py-2">
<span className="text-sm font-medium text-green-700"></span>
<span className="text-lg font-bold text-green-700">
{entriesTotal.toLocaleString()} {unit}
</span>
</div>
{/* 2행: 4 5 6 C (주황) */}
{["4", "5", "6"].map((n) => (
<button
key={n}
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick(n)}
>
{n}
</button>
))}
<button
type="button"
className="h-14 rounded-2xl bg-amber-100 text-base font-bold text-amber-600 active:bg-amber-200"
onClick={handleClear}
>
C
</button>
{/* 남은 수량 */}
{remainingQuantity > 0 && (
<div className="flex items-center justify-between rounded-lg bg-amber-50 px-3 py-2">
<span className="text-sm font-medium text-amber-700"> </span>
<span className="text-sm font-bold text-amber-700">
{remainingQuantity.toLocaleString()} {unit}
</span>
</div>
)}
{/* 3행: 1 2 3 MAX (파란) */}
{["1", "2", "3"].map((n) => (
<button
key={n}
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick(n)}
>
{n}
</button>
))}
<button
type="button"
className="h-14 rounded-2xl bg-blue-100 text-sm font-bold text-blue-600 active:bg-blue-200"
onClick={handleMax}
>
MAX
</button>
{/* 액션 버튼 */}
<div className="grid grid-cols-2 gap-2">
{remainingQuantity > 0 && (
<button
type="button"
className="flex h-12 items-center justify-center gap-1.5 rounded-2xl border border-blue-200 bg-blue-50 text-sm font-bold text-blue-600 active:bg-blue-100"
onClick={handleAddMore}
>
<Plus className="h-4 w-4" />
</button>
)}
<button
type="button"
className={`flex h-12 items-center justify-center rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600 ${remainingQuantity <= 0 ? "col-span-2" : ""}`}
onClick={handleComplete}
>
</button>
</div>
</div>
) : (
<>
{/* 숫자 표시 영역 */}
<div className="flex min-h-[72px] items-center justify-end rounded-xl border-2 border-gray-200 bg-gray-50 px-4 py-4">
{displayText ? (
<span className="text-4xl font-bold tracking-tight text-gray-900">
{displayText}
</span>
) : (
<span className="text-2xl text-gray-300">0</span>
)}
</div>
{/* 4행: 0 / 확인 (초록, 3칸) */}
<button
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick("0")}
>
0
</button>
<button
type="button"
className="col-span-3 h-14 rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600"
onClick={handleConfirm}
>
</button>
</div>
{/* 단계별 안내 텍스트 */}
<p className="text-muted-foreground text-center text-sm">
{guideMessage}
</p>
{/* 키패드 4x4 */}
<div className="grid grid-cols-4 gap-2">
{["7", "8", "9"].map((n) => (
<button
key={n}
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick(n)}
>
{n}
</button>
))}
<button
type="button"
className="flex h-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600 active:bg-amber-200"
onClick={handleBackspace}
>
<Delete className="h-5 w-5" />
</button>
{["4", "5", "6"].map((n) => (
<button
key={n}
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick(n)}
>
{n}
</button>
))}
<button
type="button"
className="h-14 rounded-2xl bg-amber-100 text-base font-bold text-amber-600 active:bg-amber-200"
onClick={handleClear}
>
C
</button>
{["1", "2", "3"].map((n) => (
<button
key={n}
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick(n)}
>
{n}
</button>
))}
<button
type="button"
className="h-14 rounded-2xl bg-blue-100 text-sm font-bold text-blue-600 active:bg-blue-200"
onClick={handleMax}
>
MAX
</button>
<button
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick("0")}
>
0
</button>
<button
type="button"
className="col-span-3 h-14 rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600"
onClick={handleConfirm}
>
{step === "package_count" ? "다음" : "확인"}
</button>
</div>
</>
)}
</div>
</DialogPrimitive.Content>
@ -201,8 +430,18 @@ export function NumberInputModal({
{/* 포장 단위 선택 모달 */}
<PackageUnitModal
open={isPackageModalOpen}
onOpenChange={setIsPackageModalOpen}
onSelect={handlePackageUnitSelect}
onOpenChange={(isOpen) => {
setIsPackageModalOpen(isOpen);
if (!isOpen && step === "summary") {
// summary에서 추가 포장 모달 닫힘 -> 단위 선택 안 한 경우 유지
}
}}
onSelect={(unitId) => {
handlePackageUnitSelect(unitId);
setIsPackageModalOpen(false);
}}
enabledUnits={packageConfig?.enabledUnits}
customUnits={packageConfig?.customUnits}
/>
</>
);

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogPortal>
@ -45,18 +60,16 @@ export function PackageUnitModal({
<DialogPrimitive.Content
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-1100 w-full max-w-[90vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-lg border shadow-lg duration-200 sm:max-w-[380px]"
>
{/* 헤더 */}
<div className="border-b px-4 py-3 pr-12">
<h2 className="text-base font-semibold">📦 </h2>
</div>
{/* 3x2 그리드 */}
<div className="grid grid-cols-3 gap-3 p-4">
{PACKAGE_UNITS.map((unit) => (
{allUnits.map((unit) => (
<button
key={unit.value}
type="button"
onClick={() => handleSelect(unit.value as PackageUnit)}
onClick={() => handleSelect(unit.value)}
className="hover:bg-muted active:bg-muted/70 flex flex-col items-center justify-center gap-2 rounded-xl border bg-background px-3 py-5 text-sm font-medium transition-colors"
>
<span className="text-2xl">{unit.emoji}</span>
@ -65,7 +78,12 @@ export function PackageUnitModal({
))}
</div>
{/* X 닫기 버튼 */}
{allUnits.length === 0 && (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
</div>
)}
<DialogClose className="ring-offset-background focus:ring-ring absolute top-3 right-3 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>

View File

@ -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<string, LucideIcon> = {
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
};
function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
if (!name) return <ShoppingCart size={size} />;
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
const IconComp = icons[name];
const IconComp = LUCIDE_ICON_MAP[name];
if (!IconComp) return <ShoppingCart size={size} />;
return <IconComp size={size} />;
}
@ -157,25 +163,58 @@ export function PopCardListComponent({
const dataSource = config?.dataSource;
const template = config?.cardTemplate;
// 이벤트 기반 company_code 필터링
const [eventCompanyCode, setEventCompanyCode] = useState<string | undefined>();
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<RowData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합)
const [externalFilters, setExternalFilters] = useState<
Map<string, {
fieldName: string;
value: unknown;
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
}>
>(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<string, unknown> = {};
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 (
<Card
key={index}
key={rowKey}
row={row}
template={template}
scaled={scaled}
inputField={config?.inputField}
calculatedField={config?.calculatedField}
packageConfig={config?.packageConfig}
cartAction={config?.cartAction}
publish={publish}
getSharedData={getSharedData}
setSharedData={setSharedData}
router={router}
onSelect={handleCardSelect}
/>
))}
);
})}
</div>
{/* 하단 컨트롤 영역 */}
@ -544,7 +604,7 @@ export function PopCardListComponent({
)}
</Button>
<span className="text-xs text-muted-foreground">
{rows.length}
{filteredRows.length}{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}` : ""}
</span>
</div>
@ -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: <T = unknown>(key: string) => T | undefined;
setSharedData: (key: string, value: unknown) => void;
router: ReturnType<typeof useRouter>;
onSelect?: (row: RowData) => void;
}) {
const header = template?.header;
const image = template?.image;
const body = template?.body;
// 입력 필드 상태
const [inputValue, setInputValue] = useState<number>(
inputField?.defaultValue || 0
);
const [inputValue, setInputValue] = useState<number>(0);
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
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<CartItem[]>("cart_items") || [];
@ -721,10 +774,18 @@ function Card({
const cartLabel = cartAction?.label || "담기";
const cancelLabel = cartAction?.cancelLabel || "취소";
const handleCardClick = () => {
onSelect?.(row);
};
return (
<div
className="rounded-lg border bg-card shadow-sm transition-all duration-150 hover:border-2 hover:border-blue-500 hover:shadow-md"
className="cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:border-2 hover:border-blue-500 hover:shadow-md"
style={cardStyle}
onClick={handleCardClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }}
>
{/* 헤더 영역 */}
{(codeValue !== null || titleValue !== null) && (
@ -777,7 +838,7 @@ function Card({
<div style={{ display: "flex", flexDirection: "column", gap: `${Math.round(scaled.gap / 2)}px` }}>
{body?.fields && body.fields.length > 0 ? (
body.fields.map((field) => (
<FieldRow key={field.id} field={field} row={row} scaled={scaled} />
<FieldRow key={field.id} field={field} row={row} scaled={scaled} inputValue={inputValue} />
))
) : (
<div
@ -787,80 +848,67 @@ function Card({
</div>
)}
{/* 계산 필드 */}
{calculatedField?.enabled && calculatedValue !== null && (
<div
className="flex items-baseline"
style={{ gap: `${Math.round(scaled.gap / 2)}px`, fontSize: `${scaled.bodyTextSize}px` }}
>
<span
className="shrink-0 text-muted-foreground"
style={{ minWidth: `${Math.round(50 * (scaled.bodyTextSize / 12))}px` }}
>
{calculatedField.label || "계산값"}
</span>
<MarqueeText className="font-medium text-orange-600">
{calculatedValue.toLocaleString()}{calculatedField.unit ? ` ${calculatedField.unit}` : ""}
</MarqueeText>
</div>
)}
</div>
</div>
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (inputField 활성화 시만) */}
{inputField?.enabled && (
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */}
{(inputField?.enabled || cartAction) && (
<div
className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2"
style={{ minWidth: "100px" }}
>
{/* 수량 버튼 */}
<button
type="button"
onClick={handleInputClick}
className="rounded-lg border-2 border-gray-300 bg-white px-2 py-1.5 text-center hover:border-primary active:bg-gray-50"
>
<span className="block text-lg font-bold leading-tight">
{inputValue.toLocaleString()}
</span>
<span className="text-muted-foreground block text-[12px]">
{inputField.unit || "EA"}
</span>
</button>
{/* 수량 버튼 (입력 필드 ON일 때만) */}
{inputField?.enabled && (
<button
type="button"
onClick={handleInputClick}
className="rounded-lg border-2 border-gray-300 bg-white px-2 py-1.5 text-center hover:border-primary active:bg-gray-50"
>
<span className="block text-lg font-bold leading-tight">
{inputValue.toLocaleString()}
</span>
<span className="text-muted-foreground block text-[12px]">
{inputField.unit || "EA"}
</span>
</button>
)}
{/* pop-icon 스타일 담기/취소 토글 버튼 */}
{isCarted ? (
<button
type="button"
onClick={handleCartCancel}
className="flex flex-col items-center justify-center gap-0.5 rounded-xl bg-destructive px-2 py-1.5 text-destructive-foreground transition-colors duration-150 hover:bg-destructive/90 active:bg-destructive/80"
>
<X size={iconSize} />
<span style={{ fontSize: `${Math.max(9, scaled.bodyTextSize - 2)}px` }} className="font-semibold leading-tight">
{cancelLabel}
</span>
</button>
) : (
<button
type="button"
onClick={handleCartAdd}
className="flex flex-col items-center justify-center gap-0.5 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700"
>
{cartAction?.iconType === "emoji" && cartAction?.iconValue ? (
<span style={{ fontSize: `${iconSize}px` }}>{cartAction.iconValue}</span>
{/* 담기/취소 버튼 (cartAction 존재 시 항상 표시) */}
{cartAction && (
<>
{isCarted ? (
<button
type="button"
onClick={handleCartCancel}
className="flex flex-col items-center justify-center gap-0.5 rounded-xl bg-destructive px-2 py-1.5 text-destructive-foreground transition-colors duration-150 hover:bg-destructive/90 active:bg-destructive/80"
>
<X size={iconSize} />
<span style={{ fontSize: `${Math.max(9, scaled.bodyTextSize - 2)}px` }} className="font-semibold leading-tight">
{cancelLabel}
</span>
</button>
) : (
<DynamicLucideIcon name={cartAction?.iconValue} size={iconSize} />
<button
type="button"
onClick={handleCartAdd}
className="flex flex-col items-center justify-center gap-0.5 rounded-xl bg-linear-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700"
>
{cartAction?.iconType === "emoji" && cartAction?.iconValue ? (
<span style={{ fontSize: `${iconSize}px` }}>{cartAction.iconValue}</span>
) : (
<DynamicLucideIcon name={cartAction?.iconValue} size={iconSize} />
)}
<span style={{ fontSize: `${Math.max(9, scaled.bodyTextSize - 2)}px` }} className="font-semibold leading-tight">
{cartLabel}
</span>
</button>
)}
<span style={{ fontSize: `${Math.max(9, scaled.bodyTextSize - 2)}px` }} className="font-semibold leading-tight">
{cartLabel}
</span>
</button>
</>
)}
</div>
)}
</div>
{/* 숫자 입력 모달 */}
{inputField?.enabled && (
<NumberInputModal
open={isModalOpen}
@ -868,8 +916,9 @@ function Card({
unit={inputField.unit || "EA"}
initialValue={inputValue}
initialPackageUnit={packageUnit}
min={inputField.min || 0}
maxValue={effectiveMax}
packageConfig={packageConfig}
showPackageUnit={inputField.showPackageUnit}
onConfirm={handleInputConfirm}
/>
)}
@ -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` }}
>
{/* 라벨 */}
<span
className="shrink-0 text-muted-foreground"
style={{ minWidth: `${labelMinWidth}px` }}
>
{field.label}
</span>
{/* 값 - 기본 검정색, textColor 설정 시 해당 색상 */}
<MarqueeText
className="font-medium"
style={{ color: field.textColor || "#000000" }}
style={{ color: field.textColor || (isFormula ? "#ea580c" : "#000000") }}
>
{formatValue(value)}
{displayValue}
</MarqueeText>
</div>
);
@ -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;
}
}

View File

@ -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"],
});

View File

@ -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<CardScrollDirection, string> =
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<string, unknown>; // 카드 원본 행 데이터
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<CardSize, CardPresetSpec> = {
},
};
// ----- 반응형 표시 모드 -----
export type ResponsiveDisplayMode = "required" | "shrink" | "hidden";
export const RESPONSIVE_DISPLAY_LABELS: Record<ResponsiveDisplayMode, string> = {
required: "필수",
shrink: "축소",
hidden: "숨김",
};
export interface CardResponsiveConfig {
code?: ResponsiveDisplayMode;
title?: ResponsiveDisplayMode;
image?: ResponsiveDisplayMode;
fields?: Record<string, ResponsiveDisplayMode>;
}
// ----- 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;
}