Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kmh 2026-02-27 07:51:02 +09:00
commit 77e9989521
27 changed files with 4704 additions and 1694 deletions

3
.gitignore vendored
View File

@ -297,4 +297,5 @@ claude.md
*-test.mjs
# 개인 작업 문서 (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

@ -143,6 +143,70 @@ export async function initializeBomVersion(req: Request, res: Response) {
}
}
// ─── BOM 엑셀 업로드/다운로드 ─────────────────────────
export async function createBomFromExcel(req: Request, res: Response) {
try {
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomFromExcel(companyCode, userId, rows);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 엑셀 업로드 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createBomVersionFromExcel(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows, versionName } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomVersionFromExcel(bomId, companyCode, userId, rows, versionName);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 엑셀 업로드 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function downloadBomExcelData(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const data = await bomService.downloadBomExcelData(bomId, companyCode);
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 엑셀 다운로드 데이터 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;

View File

@ -17,6 +17,11 @@ router.get("/:bomId/header", bomController.getBomHeader);
router.get("/:bomId/history", bomController.getBomHistory);
router.post("/:bomId/history", bomController.addBomHistory);
// 엑셀 업로드/다운로드
router.post("/excel-upload", bomController.createBomFromExcel);
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
// 버전
router.get("/:bomId/versions", bomController.getBomVersions);
router.post("/:bomId/versions", bomController.createBomVersion);

View File

@ -322,6 +322,485 @@ export async function initializeBomVersion(
});
}
// ─── BOM 엑셀 업로드 ─────────────────────────────
interface BomExcelRow {
level: number;
item_number: string;
item_name?: string;
quantity: number;
unit?: string;
process_type?: string;
remark?: string;
}
interface BomExcelUploadResult {
success: boolean;
insertedCount: number;
skippedCount: number;
errors: string[];
unmatchedItems: string[];
createdBomId?: string;
}
/**
* BOM - BOM
*
* :
* 0 = BOM ( ) bom INSERT
* 1 = bom_detail (parent_detail_id=null, DB level=0)
* 2 = bom_detail (parent_detail_id=ID, DB level=1)
* N = ... bom_detail (DB level=N-1)
*/
export async function createBomFromExcel(
companyCode: string,
userId: string,
rows: BomExcelRow[],
): Promise<BomExcelUploadResult> {
const result: BomExcelUploadResult = {
success: false,
insertedCount: 0,
skippedCount: 0,
errors: [],
unmatchedItems: [],
};
if (!rows || rows.length === 0) {
result.errors.push("업로드할 데이터가 없습니다");
return result;
}
const headerRow = rows.find(r => r.level === 0);
const detailRows = rows.filter(r => r.level > 0);
if (!headerRow) {
result.errors.push("레벨 0(BOM 마스터) 행이 필요합니다");
return result;
}
if (!headerRow.item_number?.trim()) {
result.errors.push("레벨 0(BOM 마스터)의 품번은 필수입니다");
return result;
}
if (detailRows.length === 0) {
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
return result;
}
// 레벨 유효성 검사
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.level < 0) {
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
}
if (i > 0 && row.level > rows[i - 1].level + 1) {
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다 (현재: ${row.level}, 이전: ${rows[i - 1].level})`);
}
if (row.level > 0 && !row.item_number?.trim()) {
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
}
}
if (result.errors.length > 0) {
return result;
}
return transaction(async (client) => {
// 1. 모든 품번 일괄 조회 (헤더 + 디테일)
const allItemNumbers = [...new Set(rows.filter(r => r.item_number?.trim()).map(r => r.item_number.trim()))];
const itemLookup = await client.query(
`SELECT id, item_number, item_name, unit FROM item_info
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
[companyCode, allItemNumbers],
);
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
for (const item of itemLookup.rows) {
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
}
for (const num of allItemNumbers) {
if (!itemMap.has(num)) {
result.unmatchedItems.push(num);
}
}
if (result.unmatchedItems.length > 0) {
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
return result;
}
// 2. bom 마스터 생성 (레벨 0)
const headerItemInfo = itemMap.get(headerRow.item_number.trim())!;
// 동일 품목으로 이미 BOM이 존재하는지 확인
const dupCheck = await client.query(
`SELECT id FROM bom WHERE item_id = $1 AND company_code = $2 AND status = 'active'`,
[headerItemInfo.id, companyCode],
);
if (dupCheck.rows.length > 0) {
result.errors.push(`해당 품목(${headerRow.item_number})으로 등록된 BOM이 이미 존재합니다`);
return result;
}
const bomInsert = await client.query(
`INSERT INTO bom (item_id, item_code, item_name, base_qty, unit, version, status, remark, writer, company_code)
VALUES ($1, $2, $3, $4, $5, '1.0', 'active', $6, $7, $8)
RETURNING id`,
[
headerItemInfo.id,
headerRow.item_number.trim(),
headerItemInfo.item_name,
String(headerRow.quantity || 1),
headerRow.unit || headerItemInfo.unit || null,
headerRow.remark || null,
userId,
companyCode,
],
);
const newBomId = bomInsert.rows[0].id;
result.createdBomId = newBomId;
// 3. bom_version 생성
const versionInsert = await client.query(
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, '1.0', 0, 'active', $2, $3) RETURNING id`,
[newBomId, userId, companyCode],
);
const versionId = versionInsert.rows[0].id;
await client.query(
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
[versionId, newBomId],
);
// 4. bom_detail INSERT (레벨 1+ → DB level = 엑셀 level - 1)
const levelStack: string[] = [];
const seqCounterByParent = new Map<string, number>();
for (let i = 0; i < detailRows.length; i++) {
const row = detailRows[i];
const itemInfo = itemMap.get(row.item_number.trim())!;
const dbLevel = row.level - 1;
while (levelStack.length > dbLevel) {
levelStack.pop();
}
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
const parentKey = parentDetailId || "__root__";
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
seqCounterByParent.set(parentKey, currentSeq);
const insertResult = await client.query(
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
RETURNING id`,
[
newBomId,
versionId,
parentDetailId,
itemInfo.id,
String(dbLevel),
String(currentSeq),
String(row.quantity || 1),
row.unit || itemInfo.unit || null,
row.process_type || null,
row.remark || null,
userId,
companyCode,
],
);
levelStack.push(insertResult.rows[0].id);
result.insertedCount++;
}
// 5. 이력 기록
await client.query(
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
VALUES ($1, 'excel_upload', $2, $3, $4)`,
[newBomId, `엑셀 업로드로 BOM 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
);
result.success = true;
logger.info("BOM 엑셀 업로드 - 새 BOM 생성 완료", {
newBomId, companyCode,
insertedCount: result.insertedCount,
});
return result;
});
}
/**
* BOM - BOM에
*
* 0 ( )
* 1 bom_detail로 INSERT, bom_version에
*/
export async function createBomVersionFromExcel(
bomId: string,
companyCode: string,
userId: string,
rows: BomExcelRow[],
versionName?: string,
): Promise<BomExcelUploadResult> {
const result: BomExcelUploadResult = {
success: false,
insertedCount: 0,
skippedCount: 0,
errors: [],
unmatchedItems: [],
};
if (!rows || rows.length === 0) {
result.errors.push("업로드할 데이터가 없습니다");
return result;
}
const detailRows = rows.filter(r => r.level > 0);
result.skippedCount = rows.length - detailRows.length;
if (detailRows.length === 0) {
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
return result;
}
// 레벨 유효성 검사
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.level < 0) {
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
}
if (i > 0 && row.level > rows[i - 1].level + 1) {
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다`);
}
if (row.level > 0 && !row.item_number?.trim()) {
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
}
}
if (result.errors.length > 0) {
return result;
}
return transaction(async (client) => {
// 1. BOM 존재 확인
const bomRow = await client.query(
`SELECT id, version FROM bom WHERE id = $1 AND company_code = $2`,
[bomId, companyCode],
);
if (bomRow.rows.length === 0) {
result.errors.push("BOM을 찾을 수 없습니다");
return result;
}
// 2. 품번 → item_info 매핑
const uniqueItemNumbers = [...new Set(detailRows.map(r => r.item_number.trim()))];
const itemLookup = await client.query(
`SELECT id, item_number, item_name, unit FROM item_info
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
[companyCode, uniqueItemNumbers],
);
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
for (const item of itemLookup.rows) {
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
}
for (const num of uniqueItemNumbers) {
if (!itemMap.has(num)) {
result.unmatchedItems.push(num);
}
}
if (result.unmatchedItems.length > 0) {
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
return result;
}
// 3. 버전명 결정 (미입력 시 자동 채번)
let finalVersionName = versionName?.trim();
if (!finalVersionName) {
const countResult = await client.query(
`SELECT COUNT(*)::int as cnt FROM bom_version WHERE bom_id = $1`,
[bomId],
);
finalVersionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
}
// 중복 체크
const dupCheck = await client.query(
`SELECT id FROM bom_version WHERE bom_id = $1 AND version_name = $2`,
[bomId, finalVersionName],
);
if (dupCheck.rows.length > 0) {
result.errors.push(`이미 존재하는 버전명입니다: ${finalVersionName}`);
return result;
}
// 4. bom_version 생성
const versionInsert = await client.query(
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, $2, 0, 'developing', $3, $4) RETURNING id`,
[bomId, finalVersionName, userId, companyCode],
);
const newVersionId = versionInsert.rows[0].id;
// 5. bom_detail INSERT
const levelStack: string[] = [];
const seqCounterByParent = new Map<string, number>();
for (let i = 0; i < detailRows.length; i++) {
const row = detailRows[i];
const itemInfo = itemMap.get(row.item_number.trim())!;
const dbLevel = row.level - 1;
while (levelStack.length > dbLevel) {
levelStack.pop();
}
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
const parentKey = parentDetailId || "__root__";
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
seqCounterByParent.set(parentKey, currentSeq);
const insertResult = await client.query(
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
RETURNING id`,
[
bomId,
newVersionId,
parentDetailId,
itemInfo.id,
String(dbLevel),
String(currentSeq),
String(row.quantity || 1),
row.unit || itemInfo.unit || null,
row.process_type || null,
row.remark || null,
userId,
companyCode,
],
);
levelStack.push(insertResult.rows[0].id);
result.insertedCount++;
}
// 6. BOM 헤더의 version과 current_version_id 갱신
await client.query(
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[finalVersionName, newVersionId, bomId],
);
// 7. 이력 기록
await client.query(
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
VALUES ($1, 'excel_upload', $2, $3, $4)`,
[bomId, `엑셀 업로드로 새 버전 ${finalVersionName} 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
);
result.success = true;
result.createdBomId = bomId;
logger.info("BOM 엑셀 업로드 - 새 버전 생성 완료", {
bomId, companyCode, versionName: finalVersionName,
insertedCount: result.insertedCount,
});
return result;
});
}
/**
* BOM
*
* :
* 0 = BOM ( )
* 1 = (DB level=0)
* N = DB level N-1
*
* DFS로 -
*/
export async function downloadBomExcelData(
bomId: string,
companyCode: string,
): Promise<Record<string, any>[]> {
// BOM 헤더 정보 조회 (최상위 품목)
const bomHeader = await queryOne<Record<string, any>>(
`SELECT b.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit
FROM bom b
LEFT JOIN item_info ii ON b.item_id = ii.id
WHERE b.id = $1 AND b.company_code = $2`,
[bomId, companyCode],
);
if (!bomHeader) return [];
const flatList: Record<string, any>[] = [];
// 레벨 0: BOM 헤더 (최상위 품목)
flatList.push({
level: 0,
item_number: bomHeader.item_number || "",
item_name: bomHeader.item_name || "",
quantity: bomHeader.base_qty || "1",
unit: bomHeader.item_unit || bomHeader.unit || "",
process_type: "",
remark: bomHeader.remark || "",
_is_header: true,
});
// 하위 품목 조회
const versionId = bomHeader.current_version_id;
const whereVersion = versionId ? `AND bd.version_id = $3` : `AND bd.version_id IS NULL`;
const params = versionId ? [bomId, companyCode, versionId] : [bomId, companyCode];
const details = await query(
`SELECT bd.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit, ii.size, ii.material
FROM bom_detail bd
LEFT JOIN item_info ii ON bd.child_item_id = ii.id
WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion}
ORDER BY bd.parent_detail_id NULLS FIRST, bd.seq_no::int`,
params,
);
// 부모 ID별 자식 목록으로 맵 구성
const childrenMap = new Map<string, any[]>();
const roots: any[] = [];
for (const d of details) {
if (!d.parent_detail_id) {
roots.push(d);
} else {
if (!childrenMap.has(d.parent_detail_id)) childrenMap.set(d.parent_detail_id, []);
childrenMap.get(d.parent_detail_id)!.push(d);
}
}
// DFS: depth로 정확한 레벨 계산 (DB level 무시, 실제 트리 깊이 사용)
const dfs = (nodes: any[], depth: number) => {
for (const node of nodes) {
flatList.push({
level: depth,
item_number: node.item_number || "",
item_name: node.item_name || "",
quantity: node.quantity || "1",
unit: node.unit || node.item_unit || "",
process_type: node.process_type || "",
remark: node.remark || "",
});
const children = childrenMap.get(node.id) || [];
if (children.length > 0) {
dfs(children, depth + 1);
}
}
};
// 루트 노드들은 레벨 1 (BOM 헤더가 0이므로)
dfs(roots, 1);
return flatList;
}
/**
* 삭제: 해당 version_id의 bom_detail
*/

View File

@ -9,6 +9,7 @@ services:
- "9771:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
- SERVER_API_URL=http://pms-backend-mac:8080
- NODE_OPTIONS=--max-old-space-size=8192
- NEXT_TELEMETRY_DISABLED=1
volumes:

View File

@ -0,0 +1,78 @@
# BOM 엑셀 업로드 기능 개발 계획
## 개요
탑씰(COMPANY_7) BOM관리 화면(screen_id=4168)에 엑셀 업로드 기능을 추가한다.
BOM은 트리 구조(parent_detail_id 자기참조)이므로 범용 엑셀 업로드를 사용할 수 없고,
BOM 전용 엑셀 업로드 컴포넌트를 개발한다.
## 핵심 구조
### DB 테이블
- `bom` (마스터): id(UUID), item_id(→item_info), version, current_version_id
- `bom_detail` (디테일-트리): id(UUID), bom_id(FK), parent_detail_id(자기참조), child_item_id(→item_info), level, seq_no, quantity, unit, loss_rate, process_type, version_id
- `item_info`: id, item_number(품번), item_name(품명), division(구분), unit, size, material
### 엑셀 포맷 설계 (화면과 동일한 레벨 체계)
엑셀 파일은 다음 컬럼으로 구성:
| 레벨 | 품번 | 품명 | 소요량 | 단위 | 로스율(%) | 공정구분 | 비고 |
|------|------|------|--------|------|-----------|----------|------|
| 0 | PROD-001 | 완제품A | 1 | EA | 0 | | ← BOM 헤더 (건너뜀) |
| 1 | P-001 | 부품A | 2 | EA | 0 | | ← 직접 자품목 |
| 2 | P-002 | 부품B | 3 | EA | 5 | 가공 | ← P-001의 하위 |
| 1 | P-003 | 부품C | 1 | KG | 0 | | ← 직접 자품목 |
| 2 | P-004 | 부품D | 4 | EA | 0 | 조립 | ← P-003의 하위 |
| 1 | P-005 | 부품E | 1 | EA | 0 | | ← 직접 자품목 |
- 레벨 0: BOM 헤더 (최상위 품목) → 업로드 시 건너뜀 (이미 존재)
- 레벨 1: 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0)
- 레벨 2: 자품목의 하위 → bom_detail (parent_detail_id=부모ID, DB level=1)
- 레벨 N: → bom_detail (DB level=N-1)
- 품번으로 item_info를 조회하여 child_item_id 자동 매핑
### 트리 변환 로직 (레벨 1 이상만 처리)
엑셀 행을 순서대로 순회하면서 (레벨 0 건너뜀):
1. 각 행의 엑셀 레벨에서 -1하여 DB 레벨 계산
2. 스택으로 부모-자식 관계 추적
```
행1(레벨0) → BOM 헤더, 건너뜀
행2(레벨1) → DB level=0, 스택: [행2] → parent_detail_id = null
행3(레벨2) → DB level=1, 스택: [행2, 행3] → parent_detail_id = 행2.id
행4(레벨1) → DB level=0, 스택: [행4] → parent_detail_id = null
행5(레벨2) → DB level=1, 스택: [행4, 행5] → parent_detail_id = 행4.id
행6(레벨1) → DB level=0, 스택: [행6] → parent_detail_id = null
```
## 테스트 계획
### 1단계: 백엔드 API
- [x] 테스트 1: 품번으로 item_info 일괄 조회 (존재하는 품번)
- [x] 테스트 2: 존재하지 않는 품번 에러 처리
- [x] 테스트 3: 플랫 데이터 → 트리 구조 변환 (parent_detail_id 계산)
- [x] 테스트 4: bom_detail INSERT (version_id 포함)
- [x] 테스트 5: 기존 디테일 처리 (추가 모드 vs 전체교체 모드)
### 2단계: 프론트엔드 모달
- [x] 테스트 6: 엑셀 파일 파싱 및 미리보기
- [x] 테스트 7: 품번 매핑 결과 표시 (성공/실패)
- [x] 테스트 8: 업로드 실행 및 결과 표시
### 3단계: 통합
- [x] 테스트 9: BomTreeComponent에 엑셀 업로드 버튼 추가
- [x] 테스트 10: 업로드 후 트리 자동 새로고침
## 구현 파일 목록
### 백엔드
1. `backend-node/src/services/bomService.ts` - `uploadBomExcel()` 함수 추가
2. `backend-node/src/controllers/bomController.ts` - `uploadBomExcel` 핸들러 추가
3. `backend-node/src/routes/bomRoutes.ts` - `POST /:bomId/excel-upload` 라우트 추가
### 프론트엔드
4. `frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx` - 전용 모달 신규
5. `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` - 업로드 버튼 추가
## 진행 상태
- 완료된 테스트는 [x]로 표시
- 현재 진행 중인 테스트는 [진행중]으로 표시

View File

@ -213,6 +213,8 @@ export default function ComponentEditorPanel({
previewPageIndex={previewPageIndex}
onPreviewPage={onPreviewPage}
modals={modals}
allComponents={allComponents}
connections={connections}
/>
</TabsContent>
@ -404,9 +406,11 @@ interface ComponentSettingsFormProps {
previewPageIndex?: number;
onPreviewPage?: (pageIndex: number) => void;
modals?: PopModalDefinition[];
allComponents?: PopComponentDefinitionV5[];
connections?: PopDataConnection[];
}
function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) {
function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals, allComponents, connections }: ComponentSettingsFormProps) {
// PopComponentRegistry에서 configPanel 가져오기
const registeredComp = PopComponentRegistry.getComponent(component.type);
const ConfigPanel = registeredComp?.configPanel;
@ -440,6 +444,9 @@ function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIn
onPreviewPage={onPreviewPage}
previewPageIndex={previewPageIndex}
modals={modals}
allComponents={allComponents}
connections={connections}
componentId={component.id}
/>
) : (
<div className="rounded-lg bg-gray-50 p-3">
@ -515,3 +522,4 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
);
}

View File

@ -272,6 +272,25 @@ function ConnectionForm({
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
: null;
// 보내는 값 + 받는 컴포넌트가 결정되면 받는 방식 자동 매칭
React.useEffect(() => {
if (!selectedOutput || !targetMeta?.receivable?.length) return;
// 이미 선택된 값이 있으면 건드리지 않음
if (selectedTargetInput) return;
const receivables = targetMeta.receivable;
// 1) 같은 key가 있으면 자동 매칭
const exactMatch = receivables.find((r) => r.key === selectedOutput);
if (exactMatch) {
setSelectedTargetInput(exactMatch.key);
return;
}
// 2) receivable이 1개뿐이면 자동 선택
if (receivables.length === 1) {
setSelectedTargetInput(receivables[0].key);
}
}, [selectedOutput, targetMeta, selectedTargetInput]);
// 화면에 표시 중인 컬럼
const displayColumns = React.useMemo(
() => extractDisplayColumns(targetComp || undefined),
@ -322,6 +341,8 @@ function ConnectionForm({
const handleSubmit = () => {
if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput);
onSubmit({
sourceComponent: component.id,
sourceField: "",
@ -330,7 +351,7 @@ function ConnectionForm({
targetField: "",
targetInput: selectedTargetInput,
filterConfig:
filterColumns.length > 0
!isEvent && filterColumns.length > 0
? {
targetColumn: filterColumns[0],
targetColumns: filterColumns,
@ -427,8 +448,8 @@ function ConnectionForm({
</div>
)}
{/* 필터 설정 */}
{selectedTargetInput && (
{/* 필터 설정: event 타입 연결이면 숨김 */}
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
<div className="space-y-2 rounded bg-gray-50 p-2">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
@ -607,6 +628,17 @@ function ReceiveSection({
// 유틸
// ========================================
function isEventTypeConnection(
sourceMeta: ComponentConnectionMeta | undefined,
outputKey: string,
targetMeta: ComponentConnectionMeta | null | undefined,
inputKey: string,
): boolean {
const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey);
const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey);
return sourceItem?.type === "event" || targetItem?.type === "event";
}
function buildConnectionLabel(
source: PopComponentDefinitionV5,
_outputKey: string,

View File

@ -1261,8 +1261,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
} else {
// UPDATE 모드 - PUT (전체 업데이트)
// originalData 비교 없이 formData 전체를 보냄
const recordId = formData.id;
// VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조)
const recordId = formData.master_id || formData.id;
if (!recordId) {
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
@ -1315,15 +1315,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (response.success) {
toast.success("데이터가 수정되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try {
@ -1394,6 +1385,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
// 리피터 저장 완료 후 메인 테이블 새로고침
if (modalState.onSave) {
try { modalState.onSave(); } catch {}
}
handleClose();
} else {
throw new Error(response.message || "수정에 실패했습니다.");

View File

@ -428,7 +428,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
{
page: 1,
size: 1000,
search: { [config.foreignKeyColumn]: fkValue },
dataFilter: {
enabled: true,
filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }],
},
autoFilter: true,
}
);

View File

@ -22,5 +22,9 @@ export type { PendingConfirmState } from "./usePopAction";
// 연결 해석기
export { useConnectionResolver } from "./useConnectionResolver";
// 장바구니 동기화 훅
export { useCartSync } from "./useCartSync";
export type { UseCartSyncReturn } from "./useCartSync";
// SQL 빌더 유틸 (고급 사용 시)
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";

View File

@ -0,0 +1,338 @@
/**
* useCartSync - DB
*
* DB(cart_items ) <-> (dirty) .
*
* :
* 1. DB에서 screen_id + user_id의
* 2. addItem/removeItem/updateItem은 (DB , dirty )
* 3. saveToDb DB에 (//)
* 4. isDirty = DB
*
* :
* ```typescript
* const cart = useCartSync("SCR-001", "item_info");
*
* // 품목 추가 (로컬만, DB 미반영)
* cart.addItem({ row, quantity: 10 }, "D1710008");
*
* // DB 저장 (pop-icon 확인 모달에서 호출)
* await cart.saveToDb();
* ```
*/
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { dataApi } from "@/lib/api/data";
import type {
CartItem,
CartItemWithId,
CartSyncStatus,
CartItemStatus,
} from "@/lib/registry/pop-components/types";
// ===== 반환 타입 =====
export interface UseCartSyncReturn {
cartItems: CartItemWithId[];
savedItems: CartItemWithId[];
syncStatus: CartSyncStatus;
cartCount: number;
isDirty: boolean;
loading: boolean;
addItem: (item: CartItem, rowKey: string) => void;
removeItem: (rowKey: string) => void;
updateItemQuantity: (rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => void;
isItemInCart: (rowKey: string) => boolean;
getCartItem: (rowKey: string) => CartItemWithId | undefined;
saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
loadFromDb: () => Promise<void>;
resetToSaved: () => void;
}
// ===== DB 행 -> CartItemWithId 변환 =====
function dbRowToCartItem(dbRow: Record<string, unknown>): CartItemWithId {
let rowData: Record<string, unknown> = {};
try {
const raw = dbRow.row_data;
if (typeof raw === "string" && raw.trim()) {
rowData = JSON.parse(raw);
} else if (typeof raw === "object" && raw !== null) {
rowData = raw as Record<string, unknown>;
}
} catch {
rowData = {};
}
let packageEntries: CartItem["packageEntries"] | undefined;
try {
const raw = dbRow.package_entries;
if (typeof raw === "string" && raw.trim()) {
packageEntries = JSON.parse(raw);
} else if (Array.isArray(raw)) {
packageEntries = raw;
}
} catch {
packageEntries = undefined;
}
return {
row: rowData,
quantity: Number(dbRow.quantity) || 0,
packageUnit: (dbRow.package_unit as string) || undefined,
packageEntries,
cartId: (dbRow.id as string) || undefined,
sourceTable: (dbRow.source_table as string) || "",
rowKey: (dbRow.row_key as string) || "",
status: ((dbRow.status as string) || "in_cart") as CartItemStatus,
_origin: "db",
memo: (dbRow.memo as string) || undefined,
};
}
// ===== CartItemWithId -> DB 저장용 레코드 변환 =====
function cartItemToDbRecord(
item: CartItemWithId,
screenId: string,
cartType: string = "pop",
selectedColumns?: string[],
): Record<string, unknown> {
// selectedColumns가 있으면 해당 컬럼만 추출, 없으면 전체 저장
const rowData =
selectedColumns && selectedColumns.length > 0
? Object.fromEntries(
Object.entries(item.row).filter(([k]) => selectedColumns.includes(k)),
)
: item.row;
return {
cart_type: cartType,
screen_id: screenId,
source_table: item.sourceTable,
row_key: item.rowKey,
row_data: JSON.stringify(rowData),
quantity: String(item.quantity),
unit: "",
package_unit: item.packageUnit || "",
package_entries: item.packageEntries ? JSON.stringify(item.packageEntries) : "",
status: item.status,
memo: item.memo || "",
};
}
// ===== dirty check: 두 배열의 내용이 동일한지 비교 =====
function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean {
if (a.length !== b.length) return false;
const serialize = (items: CartItemWithId[]) =>
items
.map((item) => `${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}`)
.sort()
.join("|");
return serialize(a) === serialize(b);
}
// ===== 훅 본체 =====
export function useCartSync(
screenId: string,
sourceTable: string,
cartType?: string,
): UseCartSyncReturn {
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
const [syncStatus, setSyncStatus] = useState<CartSyncStatus>("clean");
const [loading, setLoading] = useState(false);
const screenIdRef = useRef(screenId);
const sourceTableRef = useRef(sourceTable);
const cartTypeRef = useRef(cartType || "pop");
screenIdRef.current = screenId;
sourceTableRef.current = sourceTable;
cartTypeRef.current = cartType || "pop";
// ----- DB에서 장바구니 로드 -----
const loadFromDb = useCallback(async () => {
if (!screenId) return;
setLoading(true);
try {
const result = await dataApi.getTableData("cart_items", {
size: 500,
filters: {
screen_id: screenId,
cart_type: cartTypeRef.current,
status: "in_cart",
},
});
const items = (result.data || []).map(dbRowToCartItem);
setSavedItems(items);
setCartItems(items);
setSyncStatus("clean");
} catch (err) {
console.error("[useCartSync] DB 로드 실패:", err);
} finally {
setLoading(false);
}
}, [screenId]);
// 마운트 시 자동 로드
useEffect(() => {
loadFromDb();
}, [loadFromDb]);
// ----- dirty 상태 계산 -----
const isDirty = !areItemsEqual(cartItems, savedItems);
// isDirty 변경 시 syncStatus 자동 갱신
useEffect(() => {
if (syncStatus !== "saving") {
setSyncStatus(isDirty ? "dirty" : "clean");
}
}, [isDirty, syncStatus]);
// ----- 로컬 조작 (DB 미반영) -----
const addItem = useCallback(
(item: CartItem, rowKey: string) => {
setCartItems((prev) => {
const exists = prev.find((i) => i.rowKey === rowKey);
if (exists) {
return prev.map((i) =>
i.rowKey === rowKey
? { ...i, quantity: item.quantity, packageUnit: item.packageUnit, packageEntries: item.packageEntries, row: item.row }
: i,
);
}
const newItem: CartItemWithId = {
...item,
cartId: undefined,
sourceTable: sourceTableRef.current,
rowKey,
status: "in_cart",
_origin: "local",
};
return [...prev, newItem];
});
},
[],
);
const removeItem = useCallback((rowKey: string) => {
setCartItems((prev) => prev.filter((i) => i.rowKey !== rowKey));
}, []);
const updateItemQuantity = useCallback(
(rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => {
setCartItems((prev) =>
prev.map((i) =>
i.rowKey === rowKey
? {
...i,
quantity,
...(packageUnit !== undefined && { packageUnit }),
...(packageEntries !== undefined && { packageEntries }),
}
: i,
),
);
},
[],
);
const isItemInCart = useCallback(
(rowKey: string) => cartItems.some((i) => i.rowKey === rowKey),
[cartItems],
);
const getCartItem = useCallback(
(rowKey: string) => cartItems.find((i) => i.rowKey === rowKey),
[cartItems],
);
// ----- DB 저장 (일괄) -----
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
setSyncStatus("saving");
try {
const currentScreenId = screenIdRef.current;
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDelete = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
// 추가 대상: cartItems에 있지만 cartId가 없는 것 (로컬에서 추가됨)
const toCreate = cartItems.filter((c) => !c.cartId);
// 수정 대상: 양쪽 다 존재하고 cartId 있으면서 내용이 다른 것
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
const toUpdate = cartItems.filter((c) => {
if (!c.cartId) return false;
const saved = savedMap.get(c.rowKey);
if (!saved) return false;
return (
c.quantity !== saved.quantity ||
c.packageUnit !== saved.packageUnit ||
c.status !== saved.status
);
});
const promises: Promise<unknown>[] = [];
for (const item of toDelete) {
promises.push(dataApi.deleteRecord("cart_items", item.cartId!));
}
const currentCartType = cartTypeRef.current;
for (const item of toCreate) {
const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns);
promises.push(dataApi.createRecord("cart_items", record));
}
for (const item of toUpdate) {
const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns);
promises.push(dataApi.updateRecord("cart_items", item.cartId!, record));
}
await Promise.all(promises);
// 저장 후 DB에서 다시 로드하여 cartId 등을 최신화
await loadFromDb();
return true;
} catch (err) {
console.error("[useCartSync] DB 저장 실패:", err);
setSyncStatus("dirty");
return false;
}
}, [cartItems, savedItems, loadFromDb]);
// ----- 로컬 변경 취소 -----
const resetToSaved = useCallback(() => {
setCartItems(savedItems);
setSyncStatus("clean");
}, [savedItems]);
return {
cartItems,
savedItems,
syncStatus,
cartCount: cartItems.length,
isDirty,
loading,
addItem,
removeItem,
updateItemQuantity,
isItemInCart,
getCartItem,
saveToDb,
loadFromDb,
resetToSaved,
};
}

View File

@ -40,6 +40,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
const groupByColumn = rawConfig.groupByColumn;
const groupBySourceColumn = rawConfig.groupBySourceColumn || rawConfig.groupByColumn;
const targetTable = rawConfig.targetTable;
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
@ -86,8 +87,8 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
// 🆕 그룹 키 값 (예: formData.inbound_number)
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
// 🆕 그룹 키 값: groupBySourceColumn(formData 키)과 groupByColumn(DB 컬럼)을 분리
const groupKeyValue = groupBySourceColumn ? formData?.[groupBySourceColumn] : null;
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
const splitPanelPosition = screenContext?.splitPanelPosition;

View File

@ -0,0 +1,609 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import {
Upload,
FileSpreadsheet,
AlertCircle,
CheckCircle2,
Download,
Loader2,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { importFromExcel } from "@/lib/utils/excelExport";
import { apiClient } from "@/lib/api/client";
interface BomExcelUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
/** bomId가 있으면 "새 버전 등록" 모드, 없으면 "새 BOM 생성" 모드 */
bomId?: string;
bomName?: string;
}
interface ParsedRow {
rowIndex: number;
level: number;
item_number: string;
item_name: string;
quantity: number;
unit: string;
process_type: string;
remark: string;
valid: boolean;
error?: string;
isHeader?: boolean;
}
type UploadStep = "upload" | "preview" | "result";
const EXPECTED_HEADERS = ["레벨", "품번", "품명", "소요량", "단위", "공정구분", "비고"];
const HEADER_MAP: Record<string, string> = {
"레벨": "level",
"level": "level",
"품번": "item_number",
"품목코드": "item_number",
"item_number": "item_number",
"item_code": "item_number",
"품명": "item_name",
"품목명": "item_name",
"item_name": "item_name",
"소요량": "quantity",
"수량": "quantity",
"quantity": "quantity",
"qty": "quantity",
"단위": "unit",
"unit": "unit",
"공정구분": "process_type",
"공정": "process_type",
"process_type": "process_type",
"비고": "remark",
"remark": "remark",
};
export function BomExcelUploadModal({
open,
onOpenChange,
onSuccess,
bomId,
bomName,
}: BomExcelUploadModalProps) {
const isVersionMode = !!bomId;
const [step, setStep] = useState<UploadStep>("upload");
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
const [fileName, setFileName] = useState<string>("");
const [uploading, setUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<any>(null);
const [downloading, setDownloading] = useState(false);
const [versionName, setVersionName] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null);
const reset = useCallback(() => {
setStep("upload");
setParsedRows([]);
setFileName("");
setUploadResult(null);
setUploading(false);
setVersionName("");
if (fileInputRef.current) fileInputRef.current.value = "";
}, []);
const handleClose = useCallback(() => {
reset();
onOpenChange(false);
}, [reset, onOpenChange]);
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
try {
const rawData = await importFromExcel(file);
if (!rawData || rawData.length === 0) {
toast.error("엑셀 파일에 데이터가 없습니다");
return;
}
const firstRow = rawData[0];
const excelHeaders = Object.keys(firstRow);
const fieldMap: Record<string, string> = {};
for (const header of excelHeaders) {
const normalized = header.trim().toLowerCase();
const mapped = HEADER_MAP[normalized] || HEADER_MAP[header.trim()];
if (mapped) {
fieldMap[header] = mapped;
}
}
const hasItemNumber = excelHeaders.some(h => {
const n = h.trim().toLowerCase();
return HEADER_MAP[n] === "item_number" || HEADER_MAP[h.trim()] === "item_number";
});
if (!hasItemNumber) {
toast.error("품번 컬럼을 찾을 수 없습니다. 컬럼명을 확인해주세요.");
return;
}
const parsed: ParsedRow[] = [];
for (let index = 0; index < rawData.length; index++) {
const row = rawData[index];
const getField = (fieldName: string): any => {
for (const [excelKey, mappedField] of Object.entries(fieldMap)) {
if (mappedField === fieldName) return row[excelKey];
}
return undefined;
};
const levelRaw = getField("level");
const level = typeof levelRaw === "number" ? levelRaw : parseInt(String(levelRaw || "0"), 10);
const itemNumber = String(getField("item_number") || "").trim();
const itemName = String(getField("item_name") || "").trim();
const quantityRaw = getField("quantity");
const quantity = typeof quantityRaw === "number" ? quantityRaw : parseFloat(String(quantityRaw || "1"));
const unit = String(getField("unit") || "").trim();
const processType = String(getField("process_type") || "").trim();
const remark = String(getField("remark") || "").trim();
let valid = true;
let error = "";
const isHeader = level === 0;
if (!itemNumber) {
valid = false;
error = "품번 필수";
} else if (isNaN(level) || level < 0) {
valid = false;
error = "레벨 오류";
} else if (index > 0) {
const prevLevel = parsed[index - 1]?.level ?? 0;
if (level > prevLevel + 1) {
valid = false;
error = `레벨 점프 (이전: ${prevLevel})`;
}
}
parsed.push({
rowIndex: index + 1,
isHeader,
level,
item_number: itemNumber,
item_name: itemName,
quantity: isNaN(quantity) ? 1 : quantity,
unit,
process_type: processType,
remark,
valid,
error,
});
}
const filtered = parsed.filter(r => r.item_number !== "");
// 새 BOM 생성 모드: 레벨 0 필수
if (!isVersionMode) {
const hasHeader = filtered.some(r => r.level === 0);
if (!hasHeader) {
toast.error("레벨 0(BOM 마스터) 행이 필요합니다. 첫 행에 최상위 품목을 입력해주세요.");
return;
}
}
setParsedRows(filtered);
setStep("preview");
} catch (err: any) {
toast.error(`파일 파싱 실패: ${err.message}`);
}
}, [isVersionMode]);
const handleUpload = useCallback(async () => {
const invalidRows = parsedRows.filter(r => !r.valid);
if (invalidRows.length > 0) {
toast.error(`유효하지 않은 행이 ${invalidRows.length}건 있습니다.`);
return;
}
setUploading(true);
try {
const rowPayload = parsedRows.map(r => ({
level: r.level,
item_number: r.item_number,
item_name: r.item_name,
quantity: r.quantity,
unit: r.unit,
process_type: r.process_type,
remark: r.remark,
}));
let res;
if (isVersionMode) {
res = await apiClient.post(`/bom/${bomId}/excel-upload-version`, {
rows: rowPayload,
versionName: versionName.trim() || undefined,
});
} else {
res = await apiClient.post("/bom/excel-upload", { rows: rowPayload });
}
if (res.data?.success) {
setUploadResult(res.data.data);
setStep("result");
const msg = isVersionMode
? `새 버전 생성 완료: 하위품목 ${res.data.data.insertedCount}`
: `BOM 생성 완료: 하위품목 ${res.data.data.insertedCount}`;
toast.success(msg);
onSuccess?.();
} else {
const errData = res.data?.data;
if (errData?.unmatchedItems?.length > 0) {
toast.error(`매칭 안 되는 품번: ${errData.unmatchedItems.join(", ")}`);
setParsedRows(prev => prev.map(r => {
if (errData.unmatchedItems.includes(r.item_number)) {
return { ...r, valid: false, error: "품번 미등록" };
}
return r;
}));
} else {
toast.error(res.data?.message || "업로드 실패");
}
}
} catch (err: any) {
toast.error(`업로드 오류: ${err.response?.data?.message || err.message}`);
} finally {
setUploading(false);
}
}, [parsedRows, isVersionMode, bomId, versionName, onSuccess]);
const handleDownloadTemplate = useCallback(async () => {
setDownloading(true);
try {
const XLSX = await import("xlsx");
let data: Record<string, any>[] = [];
if (isVersionMode && bomId) {
// 기존 BOM 데이터를 템플릿으로 다운로드
try {
const res = await apiClient.get(`/bom/${bomId}/excel-download`);
if (res.data?.success && res.data.data?.length > 0) {
data = res.data.data.map((row: any) => ({
"레벨": row.level,
"품번": row.item_number,
"품명": row.item_name,
"소요량": row.quantity,
"단위": row.unit,
"공정구분": row.process_type,
"비고": row.remark,
}));
}
} catch { /* 데이터 없으면 빈 템플릿 */ }
}
if (data.length === 0) {
if (isVersionMode) {
data = [
{ "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" },
{ "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" },
];
} else {
data = [
{ "레벨": 0, "품번": "(최상위 품번)", "품명": "(최상위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "BOM 마스터" },
{ "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" },
{ "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" },
];
}
}
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "BOM");
ws["!cols"] = [
{ wch: 6 }, { wch: 18 }, { wch: 20 }, { wch: 10 },
{ wch: 8 }, { wch: 12 }, { wch: 20 },
];
const filename = bomName ? `BOM_${bomName}.xlsx` : "BOM_template.xlsx";
XLSX.writeFile(wb, filename);
toast.success("템플릿 다운로드 완료");
} catch (err: any) {
toast.error(`다운로드 실패: ${err.message}`);
} finally {
setDownloading(false);
}
}, [isVersionMode, bomId, bomName]);
const headerRow = parsedRows.find(r => r.isHeader);
const detailRows = parsedRows.filter(r => !r.isHeader);
const validCount = parsedRows.filter(r => r.valid).length;
const invalidCount = parsedRows.filter(r => !r.valid).length;
const title = isVersionMode ? "BOM 새 버전 엑셀 업로드" : "BOM 엑셀 업로드";
const description = isVersionMode
? `${bomName || "선택된 BOM"}의 새 버전을 엑셀 파일로 생성합니다. 레벨 0 행은 건너뜁니다.`
: "엑셀 파일로 새 BOM을 생성합니다. 레벨 0 = BOM 마스터, 레벨 1 이상 = 하위품목.";
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{title}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">{description}</DialogDescription>
</DialogHeader>
{/* Step 1: 파일 업로드 */}
{step === "upload" && (
<div className="space-y-4">
{/* 새 버전 모드: 버전명 입력 */}
{isVersionMode && (
<div>
<Label className="text-xs sm:text-sm"> ( )</Label>
<Input
value={versionName}
onChange={(e) => setVersionName(e.target.value)}
placeholder="예: 2.0"
className="h-8 text-xs sm:h-10 sm:text-sm mt-1"
/>
</div>
)}
<div
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer",
"hover:border-primary/50 hover:bg-muted/50 transition-colors",
"border-muted-foreground/25",
)}
onClick={() => fileInputRef.current?.click()}
>
<FileSpreadsheet className="mx-auto h-10 w-10 text-muted-foreground mb-3" />
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground mt-1">.xlsx, .xls, .csv </p>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={handleFileSelect}
/>
</div>
<div className="rounded-md bg-muted/50 p-3">
<p className="text-xs font-medium mb-2"> </p>
<div className="flex flex-wrap gap-1">
{EXPECTED_HEADERS.map((h, i) => (
<span
key={h}
className={cn(
"text-[10px] px-2 py-0.5 rounded-full",
i < 2 ? "bg-primary/10 text-primary font-medium" : "bg-muted text-muted-foreground",
)}
>
{h}{i < 2 ? " *" : ""}
</span>
))}
</div>
<p className="text-[10px] text-muted-foreground mt-1.5">
{isVersionMode
? "* 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목. 레벨 0 행이 있으면 건너뜁니다."
: "* 레벨 0 = BOM 마스터(최상위 품목, 1행), 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목."
}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleDownloadTemplate}
disabled={downloading}
className="w-full"
>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{isVersionMode && bomName ? `현재 BOM 데이터로 템플릿 다운로드` : "빈 템플릿 다운로드"}
</Button>
</div>
)}
{/* Step 2: 미리보기 */}
{step === "preview" && (
<div className="flex flex-col flex-1 min-h-0 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{fileName}</span>
{!isVersionMode && headerRow && (
<span className="text-xs font-medium">: {headerRow.item_number}</span>
)}
<span className="text-xs">
<span className="font-medium">{detailRows.length}</span>
</span>
{invalidCount > 0 && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="h-3 w-3" /> {invalidCount}
</span>
)}
</div>
<Button variant="ghost" size="sm" onClick={reset} className="h-7 text-xs">
<X className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="flex-1 min-h-0 overflow-auto border rounded-md">
<table className="w-full text-xs">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-2 py-1.5 text-left font-medium w-8">#</th>
<th className="px-2 py-1.5 text-left font-medium w-12"></th>
<th className="px-2 py-1.5 text-center font-medium w-12"></th>
<th className="px-2 py-1.5 text-left font-medium"></th>
<th className="px-2 py-1.5 text-left font-medium"></th>
<th className="px-2 py-1.5 text-right font-medium w-16"></th>
<th className="px-2 py-1.5 text-left font-medium w-14"></th>
<th className="px-2 py-1.5 text-left font-medium w-20"></th>
<th className="px-2 py-1.5 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{parsedRows.map((row) => (
<tr
key={row.rowIndex}
className={cn(
"border-t hover:bg-muted/30",
row.isHeader && "bg-blue-50/50",
!row.valid && "bg-destructive/5",
)}
>
<td className="px-2 py-1 text-muted-foreground">{row.rowIndex}</td>
<td className="px-2 py-1">
{row.isHeader ? (
<span className="text-[10px] text-blue-600 font-medium bg-blue-50 px-1.5 py-0.5 rounded">
{isVersionMode ? "건너뜀" : "마스터"}
</span>
) : row.valid ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
) : (
<span className="flex items-center gap-1" title={row.error}>
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
</span>
)}
</td>
<td className="px-2 py-1 text-center">
<span
className={cn(
"inline-block rounded px-1.5 py-0.5 text-[10px] font-mono",
row.isHeader ? "bg-blue-100 text-blue-700 font-medium" : "bg-muted",
)}
style={{ marginLeft: `${row.level * 8}px` }}
>
{row.level}
</span>
</td>
<td className={cn("px-2 py-1 font-mono", row.isHeader && "font-semibold")}>{row.item_number}</td>
<td className={cn("px-2 py-1", row.isHeader && "font-semibold")}>{row.item_name}</td>
<td className="px-2 py-1 text-right font-mono">{row.quantity}</td>
<td className="px-2 py-1">{row.unit}</td>
<td className="px-2 py-1">{row.process_type}</td>
<td className="px-2 py-1 text-muted-foreground truncate max-w-[100px]">{row.remark}</td>
</tr>
))}
</tbody>
</table>
</div>
{invalidCount > 0 && (
<div className="rounded-md bg-destructive/10 p-2.5 text-xs text-destructive">
<div className="font-medium mb-1"> ({invalidCount})</div>
<ul className="space-y-0.5 ml-3 list-disc">
{parsedRows.filter(r => !r.valid).slice(0, 5).map(r => (
<li key={r.rowIndex}>{r.rowIndex}: {r.error}</li>
))}
{invalidCount > 5 && <li>... {invalidCount - 5}</li>}
</ul>
</div>
)}
<div className="text-xs text-muted-foreground">
{isVersionMode
? "레벨 1 이상의 하위품목으로 새 버전을 생성합니다."
: "레벨 0 품목으로 새 BOM 마스터를 생성하고, 레벨 1 이상은 하위품목으로 등록합니다."
}
</div>
</div>
)}
{/* Step 3: 결과 */}
{step === "result" && uploadResult && (
<div className="space-y-4 py-4">
<div className="flex flex-col items-center text-center">
<div className="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center mb-3">
<CheckCircle2 className="h-7 w-7 text-green-600" />
</div>
<h3 className="text-lg font-semibold">
{isVersionMode ? "새 버전 생성 완료" : "BOM 생성 완료"}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{uploadResult.insertedCount} .
</p>
</div>
<div className={cn("grid gap-3 max-w-xs mx-auto", isVersionMode ? "grid-cols-1" : "grid-cols-2")}>
{!isVersionMode && (
<div className="rounded-lg bg-muted/50 p-3 text-center">
<div className="text-2xl font-bold text-blue-600">1</div>
<div className="text-xs text-muted-foreground">BOM </div>
</div>
)}
<div className="rounded-lg bg-muted/50 p-3 text-center">
<div className="text-2xl font-bold text-green-600">{uploadResult.insertedCount}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
{step === "upload" && (
<Button
variant="outline"
onClick={handleClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
)}
{step === "preview" && (
<>
<Button
variant="outline"
onClick={reset}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleUpload}
disabled={uploading || invalidCount > 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{uploading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> ...</>
) : (
<><Upload className="mr-2 h-4 w-4" />
{isVersionMode ? `새 버전 생성 (${detailRows.length}건)` : `BOM 생성 (${detailRows.length}건)`}
</>
)}
</Button>
</>
)}
{step === "result" && (
<Button
onClick={handleClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -14,6 +14,7 @@ import {
History,
GitBranch,
Check,
FileSpreadsheet,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -22,6 +23,7 @@ import { Button } from "@/components/ui/button";
import { BomDetailEditModal } from "./BomDetailEditModal";
import { BomHistoryModal } from "./BomHistoryModal";
import { BomVersionModal } from "./BomVersionModal";
import { BomExcelUploadModal } from "./BomExcelUploadModal";
interface BomTreeNode {
id: string;
@ -77,6 +79,7 @@ export function BomTreeComponent({
const [editTargetNode, setEditTargetNode] = useState<BomTreeNode | null>(null);
const [historyModalOpen, setHistoryModalOpen] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false);
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [colWidths, setColWidths] = useState<Record<string, number>>({});
const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => {
@ -837,6 +840,15 @@ export function BomTreeComponent({
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setExcelUploadOpen(true)}
className="h-6 gap-1 px-2 text-[10px]"
>
<FileSpreadsheet className="h-3 w-3" />
</Button>
<div className="mx-1 h-4 w-px bg-gray-200" />
<div className="flex overflow-hidden rounded-md border">
<button
@ -1149,6 +1161,18 @@ export function BomTreeComponent({
}}
/>
)}
{selectedBomId && (
<BomExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
bomId={selectedBomId}
bomName={headerInfo?.item_name || ""}
onSuccess={() => {
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
/>
)}
</div>
);
}

View File

@ -20,6 +20,7 @@ import {
Trash2,
Settings,
Move,
FileSpreadsheet,
} from "lucide-react";
import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin";
@ -43,6 +44,7 @@ import { useSplitPanel } from "./SplitPanelContext";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { PanelInlineComponent } from "./types";
import { cn } from "@/lib/utils";
import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
@ -500,6 +502,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [showAddModal, setShowAddModal] = useState(false);
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false);
// 수정 모달 상태
const [showEditModal, setShowEditModal] = useState(false);
@ -3010,12 +3013,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<CardTitle className="text-base font-semibold">
{componentConfig.leftPanel?.title || "좌측 패널"}
</CardTitle>
{!isDesignMode && componentConfig.leftPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("left")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
<div className="flex items-center gap-1">
{!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && (
<Button size="sm" variant="outline" onClick={() => setBomExcelUploadOpen(true)}>
<FileSpreadsheet className="mr-1 h-4 w-4" />
</Button>
)}
{!isDesignMode && componentConfig.leftPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("left")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
</div>
</div>
</CardHeader>
{componentConfig.leftPanel?.showSearch && (
@ -5070,6 +5081,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</DialogFooter>
</DialogContent>
</Dialog>
{(componentConfig.leftPanel as any)?.showBomExcelUpload && (
<BomExcelUploadModal
open={bomExcelUploadOpen}
onOpenChange={setBomExcelUploadOpen}
onSuccess={() => {
loadLeftData();
}}
/>
)}
</div>
);
};

View File

@ -1,6 +1,6 @@
"use client";
import { useCallback } from "react";
import React, { useCallback, useState, useEffect, useMemo } from "react";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
@ -24,8 +24,10 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { DataFlowAPI } from "@/lib/api/dataflow";
import { usePopAction } from "@/hooks/pop/usePopAction";
import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import {
Save,
Trash2,
@ -44,6 +46,8 @@ import {
Copy,
Settings,
ChevronDown,
ShoppingCart,
ShoppingBag,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
@ -113,18 +117,30 @@ export type ButtonPreset =
| "logout"
| "menu"
| "modal-open"
| "cart"
| "custom";
/** row_data 저장 모드 */
export type RowDataMode = "all" | "selected";
/** 장바구니 버튼 전용 설정 */
export interface CartButtonConfig {
cartScreenId?: string;
rowDataMode?: RowDataMode;
selectedColumns?: string[];
}
/** pop-button 전체 설정 */
export interface PopButtonConfig {
label: string;
variant: ButtonVariant;
icon?: string; // Lucide 아이콘 이름
icon?: string;
iconOnly?: boolean;
preset: ButtonPreset;
confirm?: ConfirmConfig;
action: ButtonMainAction;
followUpActions?: FollowUpAction[];
cart?: CartButtonConfig;
}
// ========================================
@ -163,6 +179,7 @@ const PRESET_LABELS: Record<ButtonPreset, string> = {
logout: "로그아웃",
menu: "메뉴 (드롭다운)",
"modal-open": "모달 열기",
cart: "장바구니 저장",
custom: "직접 설정",
};
@ -201,6 +218,8 @@ const ICON_OPTIONS: { value: string; label: string }[] = [
{ value: "Copy", label: "복사 (Copy)" },
{ value: "Settings", label: "설정 (Settings)" },
{ value: "ChevronDown", label: "아래 화살표 (ChevronDown)" },
{ value: "ShoppingCart", label: "장바구니 (ShoppingCart)" },
{ value: "ShoppingBag", label: "장바구니 담김 (ShoppingBag)" },
];
/** 프리셋별 기본 설정 */
@ -244,6 +263,13 @@ const PRESET_DEFAULTS: Record<ButtonPreset, Partial<PopButtonConfig>> = {
confirm: { enabled: false },
action: { type: "modal", modalMode: "fullscreen" },
},
cart: {
label: "장바구니 저장",
variant: "default",
icon: "ShoppingCart",
confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" },
action: { type: "event" },
},
custom: {
label: "버튼",
variant: "default",
@ -279,10 +305,42 @@ function SectionDivider({ label }: { label: string }) {
);
}
/** 장바구니 데이터 매핑 행 (읽기 전용) */
function CartMappingRow({
source,
target,
desc,
auto,
}: {
source: string;
target: string;
desc?: string;
auto?: boolean;
}) {
return (
<div className="flex items-start gap-1 py-0.5">
<span className={cn("min-w-0 flex-1 text-[10px]", auto ? "text-muted-foreground" : "text-foreground")}>
{source}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">&rarr;</span>
<div className="shrink-0 text-right">
<code className="rounded bg-muted px-1 font-mono text-[10px] text-foreground">
{target}
</code>
{desc && (
<p className="mt-0.5 text-[9px] text-muted-foreground leading-tight">{desc}</p>
)}
</div>
</div>
);
}
/** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
Save, Trash2, LogOut, Menu, ExternalLink, Plus, Check, X,
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
ShoppingCart,
ShoppingBag,
};
/** Lucide 아이콘 동적 렌더링 */
@ -309,6 +367,7 @@ interface PopButtonComponentProps {
label?: string;
isDesignMode?: boolean;
screenId?: string;
componentId?: string;
}
export function PopButtonComponent({
@ -316,8 +375,8 @@ export function PopButtonComponent({
label,
isDesignMode,
screenId,
componentId,
}: PopButtonComponentProps) {
// usePopAction 훅으로 액션 실행 통합
const {
execute,
isLoading,
@ -326,23 +385,127 @@ export function PopButtonComponent({
cancelConfirm,
} = usePopAction(screenId || "");
// 확인 메시지 결정
const { subscribe, publish } = usePopEvent(screenId || "default");
// 장바구니 모드 상태
const isCartMode = config?.preset === "cart";
const [cartCount, setCartCount] = useState(0);
const [cartIsDirty, setCartIsDirty] = useState(false);
const [cartSaving, setCartSaving] = useState(false);
const [showCartConfirm, setShowCartConfirm] = useState(false);
// 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달)
useEffect(() => {
if (!isCartMode || !componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__cart_updated`,
(payload: unknown) => {
const data = payload as { value?: { count?: number; isDirty?: boolean } } | undefined;
const inner = data?.value;
if (inner?.count !== undefined) setCartCount(inner.count);
if (inner?.isDirty !== undefined) setCartIsDirty(inner.isDirty);
}
);
return unsub;
}, [isCartMode, componentId, subscribe]);
// 저장 완료 수신 (카드 목록에서 saveToDb 완료 후 전달)
const cartScreenIdRef = React.useRef(config?.cart?.cartScreenId);
cartScreenIdRef.current = config?.cart?.cartScreenId;
useEffect(() => {
if (!isCartMode || !componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__cart_save_completed`,
(payload: unknown) => {
const data = payload as { value?: { success?: boolean } } | undefined;
setCartSaving(false);
if (data?.value?.success) {
setCartIsDirty(false);
const targetScreenId = cartScreenIdRef.current;
if (targetScreenId) {
const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim();
window.location.href = `/pop/screens/${cleanId}`;
} else {
toast.success("장바구니가 저장되었습니다.");
}
} else {
toast.error("장바구니 저장에 실패했습니다.");
}
}
);
return unsub;
}, [isCartMode, componentId, subscribe]);
const getConfirmMessage = useCallback((): string => {
if (pendingConfirm?.confirm?.message) return pendingConfirm.confirm.message;
if (config?.confirm?.message) return config.confirm.message;
return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"];
}, [pendingConfirm?.confirm?.message, config?.confirm?.message, config?.action?.type]);
// 장바구니 저장 트리거 (연결 미설정 시 10초 타임아웃으로 복구)
const cartSaveTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCartSave = useCallback(() => {
if (!componentId) return;
setCartSaving(true);
const selectedCols =
config?.cart?.rowDataMode === "selected" ? config.cart.selectedColumns : undefined;
publish(`__comp_output__${componentId}__cart_save_trigger`, {
selectedColumns: selectedCols,
});
if (cartSaveTimeoutRef.current) clearTimeout(cartSaveTimeoutRef.current);
cartSaveTimeoutRef.current = setTimeout(() => {
setCartSaving((prev) => {
if (prev) {
toast.error("장바구니 저장 응답이 없습니다. 연결 설정을 확인하세요.");
}
return false;
});
}, 10_000);
}, [componentId, publish, config?.cart?.rowDataMode, config?.cart?.selectedColumns]);
// 저장 완료 시 타임아웃 정리
useEffect(() => {
if (!cartSaving && cartSaveTimeoutRef.current) {
clearTimeout(cartSaveTimeoutRef.current);
cartSaveTimeoutRef.current = null;
}
}, [cartSaving]);
// 클릭 핸들러
const handleClick = useCallback(async () => {
// 디자인 모드: 실제 실행 안 함
if (isDesignMode) {
toast.info(
`[디자인 모드] ${ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
`[디자인 모드] ${isCartMode ? "장바구니 저장" : ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
);
return;
}
// 장바구니 모드: isDirty 여부에 따라 분기
if (isCartMode) {
if (cartCount === 0 && !cartIsDirty) {
toast.info("장바구니가 비어 있습니다.");
return;
}
if (cartIsDirty) {
// 새로 담은 항목이 있음 → 확인 후 저장
setShowCartConfirm(true);
} else {
// 이미 저장된 상태 → 바로 장바구니 화면 이동
const targetScreenId = config?.cart?.cartScreenId;
if (targetScreenId) {
const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim();
window.location.href = `/pop/screens/${cleanId}`;
} else {
toast.info("장바구니 화면이 설정되지 않았습니다.");
}
}
return;
}
const action = config?.action;
if (!action) return;
@ -350,7 +513,7 @@ export function PopButtonComponent({
confirm: config?.confirm,
followUpActions: config?.followUpActions,
});
}, [isDesignMode, config?.action, config?.confirm, config?.followUpActions, execute]);
}, [isDesignMode, isCartMode, config, cartCount, cartIsDirty, execute, handleCartSave]);
// 외형
const buttonLabel = config?.label || label || "버튼";
@ -358,30 +521,96 @@ export function PopButtonComponent({
const iconName = config?.icon || "";
const isIconOnly = config?.iconOnly || false;
// 장바구니 3상태 아이콘: 빈 장바구니 / 저장 완료 / 변경사항 있음
const cartIconName = useMemo(() => {
if (!isCartMode) return iconName;
if (cartCount === 0 && !cartIsDirty) return "ShoppingCart";
if (cartCount > 0 && !cartIsDirty) return "ShoppingBag";
return "ShoppingCart";
}, [isCartMode, cartCount, cartIsDirty, iconName]);
// 장바구니 3상태 버튼 색상
const cartButtonClass = useMemo(() => {
if (!isCartMode) return "";
if (cartCount > 0 && !cartIsDirty) {
return "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600";
}
if (cartIsDirty) {
return "bg-orange-500 hover:bg-orange-600 text-white border-orange-500 animate-pulse";
}
return "";
}, [isCartMode, cartCount, cartIsDirty]);
return (
<>
<div className="flex h-full w-full items-center justify-center">
<Button
variant={variant}
onClick={handleClick}
disabled={isLoading}
className={cn(
"transition-transform active:scale-95",
isIconOnly && "px-2"
<div className="relative">
<Button
variant={variant}
onClick={handleClick}
disabled={isLoading || cartSaving}
className={cn(
"transition-transform active:scale-95",
isIconOnly && "px-2",
cartButtonClass,
)}
>
{(isCartMode ? cartIconName : iconName) && (
<DynamicLucideIcon
name={isCartMode ? cartIconName : iconName}
size={16}
className={isIconOnly ? "" : "mr-1.5"}
/>
)}
{!isIconOnly && <span>{buttonLabel}</span>}
</Button>
{/* 장바구니 배지 */}
{isCartMode && cartCount > 0 && (
<div
className={cn(
"absolute -top-2 -right-2 flex items-center justify-center rounded-full text-[10px] font-bold",
cartIsDirty
? "bg-orange-500 text-white"
: "bg-emerald-600 text-white",
)}
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
>
{cartCount}
</div>
)}
>
{iconName && (
<DynamicLucideIcon
name={iconName}
size={16}
className={isIconOnly ? "" : "mr-1.5"}
/>
)}
{!isIconOnly && <span>{buttonLabel}</span>}
</Button>
</div>
</div>
{/* 확인 다이얼로그 (usePopAction의 pendingConfirm 상태로 제어) */}
{/* 장바구니 확인 다이얼로그 */}
<AlertDialog open={showCartConfirm} onOpenChange={setShowCartConfirm}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg">
</AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
{config?.confirm?.message || `${cartCount}개 항목을 장바구니에 저장하시겠습니까?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowCartConfirm(false);
handleCartSave();
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일반 확인 다이얼로그 */}
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
@ -420,14 +649,117 @@ export function PopButtonComponent({
interface PopButtonConfigPanelProps {
config: PopButtonConfig;
onUpdate: (config: PopButtonConfig) => void;
allComponents?: { id: string; type: string; config?: Record<string, unknown> }[];
connections?: { sourceComponent: string; targetComponent: string; sourceOutput?: string; targetInput?: string }[];
componentId?: string;
}
export function PopButtonConfigPanel({
config,
onUpdate,
allComponents,
connections,
componentId,
}: PopButtonConfigPanelProps) {
const isCustom = config?.preset === "custom";
// 컬럼 불러오기용 상태
const [loadedColumns, setLoadedColumns] = useState<{ name: string; label: string }[]>([]);
const [colLoading, setColLoading] = useState(false);
const [connectedTableName, setConnectedTableName] = useState<string | null>(null);
// 연결된 카드 목록의 테이블명 자동 탐색
useEffect(() => {
if (config?.preset !== "cart" || !componentId || !connections || !allComponents) {
setConnectedTableName(null);
return;
}
// 방법 1: 버튼(source) -> 카드목록(target) cart_save_trigger 연결
let cardListId: string | undefined;
const outConn = connections.find(
(c) =>
c.sourceComponent === componentId &&
c.sourceOutput === "cart_save_trigger",
);
if (outConn) {
cardListId = outConn.targetComponent;
}
// 방법 2: 카드목록(source) -> 버튼(target) cart_updated 연결 (역방향)
if (!cardListId) {
const inConn = connections.find(
(c) =>
c.targetComponent === componentId &&
(c.sourceOutput === "cart_updated" || c.sourceOutput === "cart_save_completed"),
);
if (inConn) {
cardListId = inConn.sourceComponent;
}
}
// 방법 3: 버튼과 연결된 pop-card-list 타입 컴포넌트 탐색
if (!cardListId) {
const anyConn = connections.find(
(c) =>
(c.sourceComponent === componentId || c.targetComponent === componentId),
);
if (anyConn) {
const otherId = anyConn.sourceComponent === componentId
? anyConn.targetComponent
: anyConn.sourceComponent;
const otherComp = allComponents.find((c) => c.id === otherId);
if (otherComp?.type === "pop-card-list") {
cardListId = otherId;
}
}
}
if (!cardListId) {
setConnectedTableName(null);
return;
}
const cardList = allComponents.find((c) => c.id === cardListId);
const cfg = cardList?.config as Record<string, unknown> | undefined;
const dataSource = cfg?.dataSource as Record<string, unknown> | undefined;
const tableName = (dataSource?.tableName as string) || (cfg?.tableName as string) || undefined;
setConnectedTableName(tableName || null);
}, [config?.preset, componentId, connections, allComponents]);
// 선택 저장 모드 + 연결 테이블명이 있으면 컬럼 자동 로드
useEffect(() => {
if (config?.cart?.rowDataMode !== "selected" || !connectedTableName) {
return;
}
// 이미 같은 테이블의 컬럼이 로드되어 있으면 스킵
if (loadedColumns.length > 0) return;
let cancelled = false;
setColLoading(true);
DataFlowAPI.getTableColumns(connectedTableName)
.then((cols) => {
if (cancelled) return;
setLoadedColumns(
cols
.filter((c: { columnName: string }) =>
!["id", "created_at", "updated_at", "created_by", "updated_by"].includes(c.columnName),
)
.map((c: { columnName: string; displayName?: string }) => ({
name: c.columnName,
label: c.displayName || c.columnName,
})),
);
})
.catch(() => {
if (!cancelled) setLoadedColumns([]);
})
.finally(() => {
if (!cancelled) setColLoading(false);
});
return () => { cancelled = true; };
}, [config?.cart?.rowDataMode, connectedTableName, loadedColumns.length]);
// 프리셋 변경 핸들러
const handlePresetChange = (preset: ButtonPreset) => {
const defaults = PRESET_DEFAULTS[preset];
@ -554,44 +886,203 @@ export function PopButtonConfigPanel({
</div>
</div>
{/* 메인 액션 */}
<SectionDivider label="메인 액션" />
<div className="space-y-2">
{/* 액션 타입 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={config?.action?.type || "save"}
onValueChange={(v) =>
updateAction({ type: v as ButtonActionType })
}
disabled={!isCustom}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ACTION_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{!isCustom && (
<p className="text-[10px] text-muted-foreground mt-1">
. &quot; &quot;
</p>
)}
</div>
{/* 장바구니 설정 (cart 프리셋 전용) */}
{config?.preset === "cart" && (
<>
<SectionDivider label="장바구니 설정" />
<div className="space-y-3">
<div>
<Label className="text-xs"> ID</Label>
<Input
value={config?.cart?.cartScreenId || ""}
onChange={(e) =>
onUpdate({
...config,
cart: { ...config.cart, cartScreenId: e.target.value },
})
}
placeholder="저장 후 이동할 POP 화면 ID"
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-[10px]">
ID입니다.
.
</p>
</div>
</div>
{/* 액션별 추가 설정 */}
<ActionDetailFields
action={config?.action}
onUpdate={updateAction}
disabled={!isCustom}
/>
</div>
{/* 데이터 저장 흐름 시각화 */}
<SectionDivider label="데이터 저장 흐름" />
<div className="space-y-2">
<p className="text-muted-foreground text-[10px]">
&quot;&quot; <code className="rounded bg-muted px-1 font-mono text-foreground">cart_items</code> .
</p>
<div className="space-y-0.5">
{/* 사용자 입력 데이터 */}
<div className="rounded-md border bg-amber-50/50 px-2.5 py-1.5 dark:bg-amber-950/20">
<p className="mb-1 text-[10px] font-medium text-amber-700 dark:text-amber-400"> </p>
<CartMappingRow source="입력한 수량" target="quantity" />
<CartMappingRow source="포장 단위" target="package_unit" />
<CartMappingRow source="포장 내역 (JSON)" target="package_entries" />
<CartMappingRow source="메모" target="memo" />
</div>
{/* 원본 데이터 */}
<div className="rounded-md border bg-blue-50/50 px-2.5 py-1.5 dark:bg-blue-950/20">
<p className="mb-1 text-[10px] font-medium text-blue-700 dark:text-blue-400"> </p>
{/* 저장 모드 선택 */}
<div className="mb-1.5 flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground"> :</span>
<Select
value={config?.cart?.rowDataMode || "all"}
onValueChange={(v) =>
onUpdate({
...config,
cart: { ...config.cart, rowDataMode: v as RowDataMode },
})
}
>
<SelectTrigger className="h-6 w-[100px] text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs"> </SelectItem>
<SelectItem value="selected" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
</div>
{config?.cart?.rowDataMode === "selected" ? (
<>
{/* 선택 저장 모드: 컬럼 목록 관리 */}
<div className="space-y-1.5">
{connectedTableName ? (
<p className="text-[10px] text-muted-foreground">
: <code className="rounded bg-muted px-1 font-mono text-foreground">{connectedTableName}</code>
</p>
) : (
<p className="text-[9px] text-amber-600 dark:text-amber-400">
(cart_save_trigger) .
</p>
)}
{colLoading && (
<p className="text-[9px] text-muted-foreground"> ...</p>
)}
{/* 불러온 컬럼 체크박스 */}
{loadedColumns.length > 0 && (
<div className="max-h-[160px] space-y-0.5 overflow-y-auto rounded border bg-background p-1.5">
{loadedColumns.map((col) => {
const isChecked = (config?.cart?.selectedColumns || []).includes(col.name);
return (
<label key={col.name} className="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 hover:bg-muted/50">
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
const prev = config?.cart?.selectedColumns || [];
const next = checked
? [...prev, col.name]
: prev.filter((c) => c !== col.name);
onUpdate({
...config,
cart: { ...config.cart, selectedColumns: next },
});
}}
className="h-3 w-3"
/>
<span className="text-[10px]">{col.label}</span>
{col.label !== col.name && (
<span className="text-[9px] text-muted-foreground">({col.name})</span>
)}
</label>
);
})}
</div>
)}
{/* 선택된 컬럼 요약 */}
{(config?.cart?.selectedColumns?.length ?? 0) > 0 ? (
<CartMappingRow
source={`선택된 ${config!.cart!.selectedColumns!.length}개 컬럼 (JSON)`}
target="row_data"
desc={config!.cart!.selectedColumns!.join(", ")}
/>
) : (
<p className="text-[9px] text-amber-600 dark:text-amber-400">
. .
</p>
)}
</div>
</>
) : (
<CartMappingRow source="행 전체 (JSON)" target="row_data" desc="원본 테이블의 모든 컬럼이 JSON으로 저장" />
)}
<CartMappingRow source="행 식별키 (PK)" target="row_key" />
<CartMappingRow source="원본 테이블명" target="source_table" />
</div>
{/* 시스템 자동 */}
<div className="rounded-md border bg-muted/30 px-2.5 py-1.5">
<p className="mb-1 text-[10px] font-medium text-muted-foreground"> </p>
<CartMappingRow source="현재 화면 ID" target="screen_id" auto />
<CartMappingRow source='장바구니 타입 ("pop")' target="cart_type" auto />
<CartMappingRow source='상태 ("in_cart")' target="status" auto />
<CartMappingRow source="회사 코드" target="company_code" auto />
<CartMappingRow source="사용자 ID" target="user_id" auto />
</div>
</div>
<p className="text-muted-foreground text-[10px] leading-relaxed">
<code className="rounded bg-muted px-1 font-mono text-foreground">row_data</code> JSON을
.
</p>
</div>
</>
)}
{/* 메인 액션 (cart 프리셋에서는 숨김) */}
{config?.preset !== "cart" && (
<>
<SectionDivider label="메인 액션" />
<div className="space-y-2">
<div>
<Label className="text-xs"> </Label>
<Select
value={config?.action?.type || "save"}
onValueChange={(v) =>
updateAction({ type: v as ButtonActionType })
}
disabled={!isCustom}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ACTION_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{!isCustom && (
<p className="text-[10px] text-muted-foreground mt-1">
. &quot; &quot;
</p>
)}
</div>
<ActionDetailFields
action={config?.action}
onUpdate={updateAction}
disabled={!isCustom}
/>
</div>
</>
)}
{/* 확인 다이얼로그 */}
<SectionDivider label="확인 메시지" />
@ -980,7 +1471,7 @@ function PopButtonPreviewComponent({
PopComponentRegistry.registerComponent({
id: "pop-button",
name: "버튼",
description: "액션 버튼 (저장/삭제/API/모달/이벤트)",
description: "액션 버튼 (저장/삭제/API/모달/이벤트/장바구니)",
category: "action",
icon: "MousePointerClick",
component: PopButtonComponent,
@ -993,6 +1484,15 @@ PopComponentRegistry.registerComponent({
confirm: { enabled: false },
action: { type: "save" },
} as PopButtonConfig,
connectionMeta: {
sendable: [
{ key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" },
],
receivable: [
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "카드 목록에서 DB 저장 완료 시 수신" },
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -1,18 +1,26 @@
"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,
DialogOverlay,
DialogTitle,
} from "@/components/ui/dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import {
PackageUnitModal,
PACKAGE_UNITS,
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 +30,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 +44,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 = Math.max(0, 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"
? Math.max(1, remainingQuantity)
: 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 = Math.min(packageCount * numericValue, remainingQuantity);
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 +230,200 @@ 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"
>
{/* 파란 헤더 */}
<VisuallyHidden><DialogTitle> </DialogTitle></VisuallyHidden>
{/* 헤더 */}
<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 +433,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

@ -2,13 +2,16 @@
import React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { X } from "lucide-react";
import {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTitle,
} from "@/components/ui/dialog";
import type { CustomPackageUnit } from "../types";
export const PACKAGE_UNITS = [
{ value: "box", label: "박스", emoji: "📦" },
@ -24,19 +27,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 +62,17 @@ 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]"
>
{/* 헤더 */}
<VisuallyHidden><DialogTitle> </DialogTitle></VisuallyHidden>
<div className="border-b px-4 py-3 pr-12">
<h2 className="text-base font-semibold">📦 </h2>
<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 +81,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,
@ -31,13 +35,16 @@ import {
} from "../types";
import { dataApi } from "@/lib/api/data";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useCartSync } from "@/hooks/pop/useCartSync";
import { NumberInputModal } from "./NumberInputModal";
// 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 +164,92 @@ 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 { subscribe, publish } = 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]);
// 장바구니 DB 동기화
const sourceTableName = dataSource?.tableName || "";
const cartType = config?.cartAction?.cartType;
const cart = useCartSync(screenId || "", sourceTableName, cartType);
// 데이터 상태
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]);
// cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용
const cartRef = useRef(cart);
cartRef.current = cart;
// "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__cart_save_trigger`,
async (payload: unknown) => {
const data = payload as { value?: { selectedColumns?: string[] } } | undefined;
const ok = await cartRef.current.saveToDb(data?.value?.selectedColumns);
publish(`__comp_output__${componentId}__cart_save_completed`, {
success: ok,
});
}
);
return unsub;
}, [componentId, subscribe, publish]);
// DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달
useEffect(() => {
if (!componentId || cart.loading) return;
publish(`__comp_output__${componentId}__cart_updated`, {
count: cart.cartCount,
isDirty: cart.isDirty,
});
}, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish]);
// 카드 선택 시 selected_row 이벤트 발행
const handleCardSelect = useCallback((row: RowData) => {
if (!componentId) return;
publish(`__comp_output__${componentId}__selected_row`, row);
}, [componentId, publish]);
// 확장/페이지네이션 상태
const [isExpanded, setIsExpanded] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
@ -190,7 +264,9 @@ export function PopCardListComponent({
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
if (width > 0) setContainerWidth(width);
if (height > 0) setContainerHeight(height);
});
@ -200,9 +276,10 @@ export function PopCardListComponent({
// 이미지 URL 없는 항목 카운트 (toast 중복 방지용)
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(() => {
@ -216,67 +293,82 @@ export function PopCardListComponent({
const autoColumns = containerWidth > 0
? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap)))
: maxGridColumns;
const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns);
const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns));
// 컨테이너 높이 기반 행 수 자동 계산 (잘림 방지)
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 +376,7 @@ export function PopCardListComponent({
}, [gridColumns, gridRows]);
// 더보기 버튼 표시 여부
const hasMoreCards = rows.length > visibleCardCount;
const hasMoreCards = filteredRows.length > visibleCardCount;
// 확장 상태에서 표시할 카드 수 계산
const expandedCardsPerPage = useMemo(() => {
@ -300,19 +392,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 +448,12 @@ export function PopCardListComponent({
}
}, [currentPage, isExpanded]);
// 데이터 조회
// dataSource를 직렬화해서 의존성 안정화 (객체 참조 변경에 의한 불필요한 재호출 방지)
const dataSourceKey = useMemo(
() => JSON.stringify(dataSource || null),
[dataSource]
);
useEffect(() => {
if (!dataSource?.tableName) {
setLoading(false);
@ -370,10 +465,8 @@ export function PopCardListComponent({
setLoading(true);
setError(null);
missingImageCountRef.current = 0;
toastShownRef.current = false;
try {
// 필터 조건 구성
const filters: Record<string, unknown> = {};
if (dataSource.filters && dataSource.filters.length > 0) {
dataSource.filters.forEach((f) => {
@ -383,28 +476,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,27 +510,13 @@ export function PopCardListComponent({
};
fetchData();
}, [dataSource, eventCompanyCode]);
}, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps
// 이미지 URL 없는 항목 체크 및 toast 표시
// 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시)
useEffect(() => {
if (
!loading &&
rows.length > 0 &&
template?.image?.enabled &&
template?.image?.imageColumn &&
!toastShownRef.current
) {
if (!loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn) {
const imageColumn = template.image.imageColumn;
const missingCount = rows.filter((row) => !row[imageColumn]).length;
if (missingCount > 0) {
missingImageCountRef.current = missingCount;
toastShownRef.current = true;
toast.warning(
`${missingCount}개 항목의 이미지 URL이 없어 기본 이미지로 표시됩니다`
);
}
missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length;
}
}, [loading, rows, template?.image]);
@ -503,21 +579,29 @@ export function PopCardListComponent({
justifyContent: isHorizontalMode ? "start" : "center",
}}
>
{displayCards.map((row, index) => (
{displayCards.map((row, index) => {
const codeValue = template?.header?.codeField && row[template.header.codeField]
? String(row[template.header.codeField])
: null;
const rowKey = codeValue ? `${codeValue}-${index}` : `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}
cart={cart}
codeFieldName={template?.header?.codeField}
parentComponentId={componentId}
/>
))}
);
})}
</div>
{/* 하단 컨트롤 영역 */}
@ -544,7 +628,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 +673,75 @@ function Card({
template,
scaled,
inputField,
calculatedField,
packageConfig,
cartAction,
publish,
getSharedData,
setSharedData,
router,
onSelect,
cart,
codeFieldName,
parentComponentId,
}: {
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;
cart: ReturnType<typeof useCartSync>;
codeFieldName?: string;
parentComponentId?: string;
}) {
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 결정
// 장바구니 상태: codeField 값을 rowKey로 사용
const rowKey = codeFieldName && row[codeFieldName] ? String(row[codeFieldName]) : "";
const isCarted = cart.isItemInCart(rowKey);
const existingCartItem = cart.getCartItem(rowKey);
// DB에서 로드된 장바구니 품목이면 입력값 복원
useEffect(() => {
if (existingCartItem && existingCartItem._origin === "db") {
setInputValue(existingCartItem.quantity);
setPackageUnit(existingCartItem.packageUnit);
setPackageEntries(existingCartItem.packageEntries || []);
}
}, [existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]);
const imageUrl =
image?.enabled && image?.imageColumn && row[image.imageColumn]
? String(row[image.imageColumn])
: 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,43 +767,43 @@ 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 누적 + 이벤트 발행 + 토글
// 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달
const handleCartAdd = () => {
if (!rowKey) return;
const cartItem: CartItem = {
row,
quantity: inputValue,
packageUnit: packageUnit || undefined,
packageEntries: packageEntries.length > 0 ? packageEntries : undefined,
};
const existing = getSharedData<CartItem[]>("cart_items") || [];
setSharedData("cart_items", [...existing, cartItem]);
publish("cart_item_added", cartItem);
setIsCarted(true);
toast.success("장바구니에 담겼습니다.");
if (cartAction?.navigateMode === "screen" && cartAction.targetScreenId) {
router.push(`/pop/screens/${cartAction.targetScreenId}`);
cart.addItem(cartItem, rowKey);
if (parentComponentId) {
publish(`__comp_output__${parentComponentId}__cart_updated`, {
count: cart.cartCount + 1,
isDirty: true,
});
}
};
// 취소: sharedData에서 해당 아이템 제거 + 이벤트 발행 + 토글 복원
// 취소: 로컬 상태에서만 제거 + 연결 시스템으로 카운트 전달
const handleCartCancel = () => {
const existing = getSharedData<CartItem[]>("cart_items") || [];
const rowKey = JSON.stringify(row);
const filtered = existing.filter(
(item) => JSON.stringify(item.row) !== rowKey
);
setSharedData("cart_items", filtered);
publish("cart_item_removed", { row });
if (!rowKey) return;
setIsCarted(false);
toast.info("장바구니에서 제거되었습니다.");
cart.removeItem(rowKey);
if (parentComponentId) {
publish(`__comp_output__${parentComponentId}__cart_updated`, {
count: Math.max(0, cart.cartCount - 1),
isDirty: true,
});
}
};
// pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영)
@ -721,14 +811,26 @@ 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:shadow-md ${
isCarted
? "border-emerald-500 border-2 hover:border-emerald-600"
: "hover:border-2 hover:border-blue-500"
}`}
style={cardStyle}
onClick={handleCardClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }}
>
{/* 헤더 영역 */}
{(codeValue !== null || titleValue !== null) && (
<div className="border-b bg-muted/30" style={headerStyle}>
<div className={`border-b ${isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30"}`} style={headerStyle}>
<div className="flex items-center gap-2">
{codeValue !== null && (
<span
@ -777,7 +879,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 +889,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 +957,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 +973,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 +1028,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 +1110,6 @@ function evaluateFormula(
// 안전한 계산 (기본 산술 연산만 허용)
// 허용: 숫자, +, -, *, /, (, ), 공백, 소수점
if (!/^[\d\s+\-*/().]+$/.test(expression)) {
console.warn("Invalid formula expression:", expression);
return null;
}
@ -995,7 +1122,7 @@ function evaluateFormula(
return Math.round(result * 100) / 100; // 소수점 2자리까지
} catch (error) {
console.warn("Formula evaluation error:", error);
// 수식 평가 실패 시 null 반환
return null;
}
}

View File

@ -41,9 +41,7 @@ const defaultConfig: PopCardListConfig = {
gridRows: 2,
// 담기 버튼 기본 설정
cartAction: {
navigateMode: "none",
iconType: "lucide",
iconValue: "ShoppingCart",
saveMode: "cart",
label: "담기",
cancelLabel: "취소",
},
@ -60,6 +58,17 @@ PopComponentRegistry.registerComponent({
configPanel: PopCardListConfigPanel,
preview: PopCardListPreviewComponent,
defaultProps: defaultConfig,
connectionMeta: {
sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" },
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "장바구니 변경 시 count/isDirty 전달" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
],
receivable: [
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
{ key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -13,7 +13,20 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import type {
PopSearchConfig,
SearchInputType,
@ -379,6 +392,7 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
const [columnsLoading, setColumnsLoading] = useState(false);
const [openTableCombo, setOpenTableCombo] = useState(false);
useEffect(() => {
let cancelled = false;
@ -455,23 +469,62 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
...
</div>
) : (
<Select
value={mc.tableName || undefined}
onValueChange={(v) =>
updateModal({ tableName: v, displayColumns: [], searchColumns: [], displayField: "", valueField: "", columnLabels: undefined })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((t) => (
<SelectItem key={t.tableName} value={t.tableName} className="text-xs">
{t.displayName || t.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombo}
className="h-8 w-full justify-between text-xs"
>
{mc.tableName
? tables.find((t) => t.tableName === mc.tableName)?.displayName || mc.tableName
: "테이블 선택"}
<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-3 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{tables.map((t) => (
<CommandItem
key={t.tableName}
value={`${t.displayName || ""} ${t.tableName}`}
onSelect={() => {
updateModal({
tableName: t.tableName,
displayColumns: [],
searchColumns: [],
displayField: "",
valueField: "",
columnLabels: undefined,
});
setOpenTableCombo(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mc.tableName === t.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{t.displayName || t.tableName}</span>
{t.displayName && t.displayName !== t.tableName && (
<span className="text-[9px] text-muted-foreground">{t.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>

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,44 +455,84 @@ 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; // 표시명 (예: "파렛트")
}
// ----- 담기 버튼 데이터 구조 (추후 API 연동 시 이 shape 그대로 사용) -----
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
}
// ----- 담기 버튼 데이터 구조 (로컬 상태용) -----
export interface CartItem {
row: Record<string, unknown>; // 카드 원본 행 데이터
quantity: number; // 입력 수량
packageUnit?: string; // 포장 단위 (box/bag/pack/bundle/roll/barrel)
packageEntries?: PackageEntry[]; // 포장 내역 (2단계 계산 시)
}
// ----- 장바구니 DB 연동용 확장 타입 -----
export type CartSyncStatus = "clean" | "dirty" | "saving";
export type CartItemOrigin = "db" | "local";
export type CartItemStatus = "in_cart" | "confirmed" | "cancelled";
export interface CartItemWithId extends CartItem {
cartId?: string; // DB id (UUID, 저장 후 할당)
sourceTable: string; // 원본 테이블명
rowKey: string; // 원본 행 식별키 (codeField 값)
status: CartItemStatus;
_origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가
memo?: string;
}
// ----- 담기 버튼 액션 설정 (pop-icon 스타일 + 장바구니 연동) -----
export type CartSaveMode = "cart" | "direct";
export interface CardCartActionConfig {
navigateMode: "none" | "screen"; // 담기 후 이동 모드
targetScreenId?: string; // 장바구니 POP 화면 ID (screen 모드)
iconType?: "lucide" | "emoji"; // 아이콘 타입
iconValue?: string; // Lucide 아이콘명 또는 이모지 값
label?: string; // 담기 라벨 (기본: "담기")
cancelLabel?: string; // 취소 라벨 (기본: "취소")
saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장
cartType?: string; // 장바구니 구분값 (예: "purchase_inbound")
label?: string; // 담기 라벨 (기본: "담기")
cancelLabel?: string; // 취소 라벨 (기본: "취소")
// 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호)
dataFields?: { sourceField: string; targetField?: string; label?: string }[];
// 하위 호환: 기존 필드 (사용하지 않지만 기존 데이터 보호)
navigateMode?: "none" | "screen";
targetScreenId?: string;
iconType?: "lucide" | "emoji";
iconValue?: string;
}
// ----- pop-card-list 전체 설정 -----
@ -533,31 +591,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;
}

View File

@ -23,7 +23,8 @@ const nextConfig = {
// Docker 환경: 컨테이너 이름(pms-backend-mac) 또는 SERVER_API_URL 사용
// 로컬 개발: http://127.0.0.1:8080 사용
async rewrites() {
const backendUrl = process.env.SERVER_API_URL || "http://127.0.0.1:8080";
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
const backendUrl = process.env.SERVER_API_URL || "http://localhost:8080";
return [
{
source: "/api/:path*",
@ -48,7 +49,8 @@ const nextConfig = {
// 환경 변수 (런타임에 읽기)
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8080/api",
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api",
},
};