Compare commits
4 Commits
f90bf63354
...
c161957cfe
| Author | SHA1 | Date |
|---|---|---|
|
|
c161957cfe | |
|
|
0e0d433ce3 | |
|
|
0ca031282b | |
|
|
7a97603106 |
|
|
@ -298,3 +298,4 @@ claude.md
|
||||||
|
|
||||||
# 개인 작업 문서 (popdocs)
|
# 개인 작업 문서 (popdocs)
|
||||||
popdocs/
|
popdocs/
|
||||||
|
.cursor/rules/popdocs-safety.mdc
|
||||||
733
PLAN.MD
733
PLAN.MD
|
|
@ -1,404 +1,202 @@
|
||||||
# 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성
|
# 현재 구현 계획: pop-card-list 입력 필드/계산 필드 구조 개편
|
||||||
|
|
||||||
> **작성일**: 2026-02-10
|
> **작성일**: 2026-02-24
|
||||||
> **상태**: 코딩 완료 (방어 로직 패치 포함)
|
> **상태**: 계획 완료, 코딩 대기
|
||||||
> **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성
|
> **목적**: 입력 필드 설정 단순화 + 본문 필드에 계산식 통합 + 기존 계산 필드 섹션 제거
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 문제 요약
|
## 1. 변경 개요
|
||||||
|
|
||||||
pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가.
|
### 배경
|
||||||
|
- 기존: "입력 필드", "계산 필드", "담기 버튼" 3개가 별도 섹션으로 분리
|
||||||
|
- 문제: 계산 필드가 본문 필드와 동일한 위치에 표시되어야 하는데 별도 영역에 있음
|
||||||
|
- 문제: 입력 필드의 min/max 고정값은 비실용적 (실제로는 DB 컬럼 기준 제한이 필요)
|
||||||
|
- 문제: step, columnName, sourceColumns, resultColumn 등 죽은 코드 존재
|
||||||
|
|
||||||
| # | 문제 | 심각도 | 영향 |
|
### 목표
|
||||||
|---|------|--------|------|
|
1. **본문 필드에 계산식 지원 추가** - 필드별로 "DB 컬럼" 또는 "계산식" 선택
|
||||||
| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 |
|
2. **입력 필드 설정 단순화** - 고정 min/max 제거, 제한 기준 컬럼 방식으로 변경
|
||||||
| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 |
|
3. **기존 "계산 필드" 섹션 제거** - 본문 필드에 통합되므로 불필요
|
||||||
| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 |
|
4. **죽은 코드 정리**
|
||||||
| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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 부근, 집계 함수 아래)
|
**현재 코드** (라인 367~372):
|
||||||
|
```typescript
|
||||||
집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가.
|
export interface CardFieldBinding {
|
||||||
|
id: string;
|
||||||
**추가할 위치**: `{/* 집계 함수 + 대상 컬럼 */}` 블록 다음, `{/* 자동 새로고침 */}` 블록 이전
|
columnName: string;
|
||||||
|
label: string;
|
||||||
**추가할 코드** (약 50줄):
|
textColor?: string;
|
||||||
|
|
||||||
```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} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**변경 코드**:
|
**변경 코드**:
|
||||||
```tsx
|
```typescript
|
||||||
case "stat-card": {
|
export interface CardFieldBinding {
|
||||||
const categoryData: Record<string, number> = {};
|
id: string;
|
||||||
if (item.statConfig?.categories) {
|
label: string;
|
||||||
for (const cat of item.statConfig.categories) {
|
textColor?: string;
|
||||||
if (cat.filter.column && cat.filter.value) {
|
valueType: "column" | "formula"; // 값 유형: DB 컬럼 또는 계산식
|
||||||
// 카테고리 필터로 rows 필터링
|
columnName?: string; // valueType === "column"일 때 사용
|
||||||
const filtered = itemData.rows.filter((row) => {
|
formula?: string; // valueType === "formula"일 때 사용 (예: "$input - received_qty")
|
||||||
const cellValue = String(row[cat.filter.column] ?? "");
|
unit?: string; // 계산식일 때 단위 표시 (예: "EA")
|
||||||
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} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**주의**: 이 방식은 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] |
|
| 1 | A-1: CardFieldBinding 타입 확장 | types.ts | 없음 | [ ] |
|
||||||
| 2 | A-2: 차트 xAxisColumn 입력 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
| 2 | A-2: CardInputFieldConfig 단순화 | types.ts | 없음 | [ ] |
|
||||||
| 3 | A-3: 통계 카드 카테고리 설정 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
| 3 | A-3: CardCalculatedFieldConfig 제거 | types.ts | 없음 | [ ] |
|
||||||
| 4 | B-1: 차트 xAxisColumn 자동 보정 로직 | PopDashboardComponent.tsx | 순서 1 | [x] |
|
| 4 | B-1: FieldEditor에 값 유형 선택 추가 | PopCardListConfig.tsx | 순서 1 | [ ] |
|
||||||
| 5 | B-2: 통계 카드 카테고리별 필터 적용 | PopDashboardComponent.tsx | 순서 3 | [x] |
|
| 5 | B-2: 입력 필드 설정 섹션 개편 | PopCardListConfig.tsx | 순서 2 | [ ] |
|
||||||
| 6 | 린트 검사 | 전체 | 순서 1~5 | [x] |
|
| 6 | B-3: 계산 필드 섹션 제거 | PopCardListConfig.tsx | 순서 3 | [ ] |
|
||||||
| 7 | C-1: SQL 빌더 방어 로직 (빈 컬럼/테이블 차단) | dataFetcher.ts | 없음 | [x] |
|
| 7 | B-4: import 정리 | PopCardListConfig.tsx | 순서 6 | [ ] |
|
||||||
| 8 | C-2: refreshInterval 최소값 강제 (5초) | PopDashboardComponent.tsx | 없음 | [x] |
|
| 8 | D-1: NumberInputModal showPackageUnit 추가 | NumberInputModal.tsx | 없음 | [ ] |
|
||||||
| 9 | 브라우저 테스트 | - | 순서 1~8 | [ ] |
|
| 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은 서로 독립이므로 병렬 가능.
|
순서 1, 2, 3은 독립이므로 병렬 가능.
|
||||||
순서 4는 순서 1의 groupBy 값이 있어야 의미 있음.
|
순서 8은 독립이므로 병렬 가능.
|
||||||
순서 5는 순서 3의 카테고리 설정이 있어야 의미 있음.
|
|
||||||
순서 7, 8은 백엔드 부하 방지를 위한 방어 패치.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -429,28 +228,21 @@ case "stat-card": {
|
||||||
|
|
||||||
| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 |
|
| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 |
|
||||||
|--------|------|-----------|-----------|-----------|
|
|--------|------|-----------|-----------|-----------|
|
||||||
| `groupByOpen` | state (boolean) | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 |
|
| `valueType` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 |
|
||||||
| `setGroupByOpen` | state setter | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 |
|
| `formula` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 (기존 CardCalculatedFieldConfig.formula와 다른 인터페이스) |
|
||||||
| `chartItem` | const (DashboardItem) | PopDashboardComponent.tsx renderSingleItem 내부 | 동일 함수 내부 | 충돌 없음 |
|
| `limitColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 |
|
||||||
|
| `saveTable` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 |
|
||||||
**Grep 검색 결과** (전체 pop-dashboard 폴더):
|
| `saveColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 |
|
||||||
- `groupByOpen`: 0건 - 충돌 없음
|
| `showPackageUnit` | CardInputFieldConfig 속성 / NumberInputModal prop | types.ts, NumberInputModal.tsx | PopCardListComponent.tsx | 충돌 없음 |
|
||||||
- `setGroupByOpen`: 0건 - 충돌 없음
|
|
||||||
- `groupByColumns`: 0건 - 충돌 없음
|
|
||||||
- `chartItem`: 0건 - 충돌 없음
|
|
||||||
- `StatCategoryEditor`: 0건 - 충돌 없음
|
|
||||||
- `loadCategoryData`: 0건 - 충돌 없음
|
|
||||||
|
|
||||||
### 기존 타입/함수 재사용 목록
|
### 기존 타입/함수 재사용 목록
|
||||||
|
|
||||||
| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 |
|
| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 |
|
||||||
|------------|-----------|------------------------|
|
|------------|-----------|------------------------|
|
||||||
| `DataSourceConfig.aggregation.groupBy` | types.ts 라인 155 | A-1 UI에서 읽기/쓰기 |
|
| `evaluateFormula()` | PopCardListComponent.tsx 라인 1026 | C-1: FieldRow에서 호출 (기존 함수 그대로 재사용) |
|
||||||
| `ChartItemConfig.xAxisColumn` | types.ts 라인 248 | A-2 UI, B-1 자동 보정 |
|
| `CardFieldBinding` | types.ts 라인 367 | A-1에서 수정, B-1/C-1에서 사용 |
|
||||||
| `StatCategory` | types.ts 라인 261 | A-3 카테고리 편집 |
|
| `CardInputFieldConfig` | types.ts 라인 443 | A-2에서 수정, B-2/C-4에서 사용 |
|
||||||
| `StatCardConfig.categories` | types.ts 라인 268 | A-3 UI에서 읽기/쓰기 |
|
| `GroupedColumnSelect` | PopCardListConfig.tsx | B-1: 계산식 모드에서 컬럼 칩 표시에 재사용 가능 |
|
||||||
| `FilterOperator` | types.ts (import 이미 존재) | A-3 카테고리 필터 Select |
|
|
||||||
| `columns` (state) | PopDashboardConfig.tsx DataSourceEditor 내부 | A-1 groupBy 컬럼 목록 |
|
|
||||||
|
|
||||||
**사용처 있는데 정의 누락된 항목: 없음**
|
**사용처 있는데 정의 누락된 항목: 없음**
|
||||||
|
|
||||||
|
|
@ -458,61 +250,81 @@ case "stat-card": {
|
||||||
|
|
||||||
## 5. 에러 함정 경고
|
## 5. 에러 함정 경고
|
||||||
|
|
||||||
### 함정 1: 차트에 groupBy만 설정하고 xAxisColumn을 비우면
|
### 함정 1: 기존 저장 데이터 하위 호환
|
||||||
ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `{status: "수주", value: 79}` 형태.
|
기존에 저장된 `CardFieldBinding`에는 `valueType`이 없고 `columnName`이 필수였음.
|
||||||
`name` 키가 없으므로 X축이 빈 채로 렌더링됨.
|
**반드시** 런타임에서 `field.valueType || "column"` 폴백 처리해야 함.
|
||||||
**B-1의 자동 보정 로직이 필수**. 순서 4를 빠뜨리면 차트가 깨짐.
|
Config UI에서도 `valueType` 미존재 시 `"column"` 기본값 적용 필요.
|
||||||
|
|
||||||
### 함정 2: 통계 카드에 집계 함수를 설정하면
|
### 함정 2: CardInputFieldConfig 하위 호환
|
||||||
집계(COUNT 등)가 설정되면 rows에 `[{value: 87}]` 하나만 들어옴.
|
기존 `maxColumn`이 `limitColumn`으로 이름 변경됨.
|
||||||
카테고리별 필터링이 작동하려면 **집계 함수를 "없음"으로 두거나, groupBy를 설정**해야 개별 행이 rows에 포함됨.
|
기존 저장 데이터의 `maxColumn`을 `limitColumn`으로 읽어야 함.
|
||||||
통계 카드에서는 **집계를 사용하지 않는 것이 올바른 사용법**.
|
런타임: `inputField?.limitColumn || (inputField as any)?.maxColumn` 폴백 필요.
|
||||||
설정 가이드 문서에 이 점을 명시해야 함.
|
|
||||||
|
|
||||||
### 함정 3: PopDashboardConfig.tsx의 import 누락
|
### 함정 3: evaluateFormula의 inputValue 전달
|
||||||
현재 `FilterOperator`는 이미 import되어 있음 (라인 54).
|
FieldRow에 `inputValue`를 전달하려면, CardItem -> body.fields.map -> FieldRow 경로에서 `inputValue` prop을 추가해야 함.
|
||||||
`StatCategory`는 직접 사용하지 않고 `item.statConfig.categories` 구조로 접근하므로 import 불필요.
|
입력 필드가 비활성화된 경우 `inputValue`는 0으로 전달.
|
||||||
**새로운 import 추가 필요 없음.**
|
|
||||||
|
|
||||||
### 함정 4: 통계 카드 카테고리 필터에서 숫자 비교
|
### 함정 4: calculatedField 제거 시 기존 데이터
|
||||||
`String(row[col])` vs `String(filter.value)` 비교이므로, 숫자 컬럼도 문자열로 비교됨.
|
기존 config에 `calculatedField` 데이터가 남아 있을 수 있음.
|
||||||
`"100" === "100"`은 정상 동작하지만, `"100.00" !== "100"`이 될 수 있음.
|
타입에서 제거하더라도 런타임 에러는 나지 않음 (unknown 속성은 무시됨).
|
||||||
현재 대부분 컬럼이 varchar이므로 문제없지만, numeric 컬럼 사용 시 주의.
|
다만 이전에 계산 필드로 설정한 내용은 사라짐 - 마이그레이션 없이 제거.
|
||||||
|
|
||||||
### 함정 5: DataSourceEditor의 columns state 타이밍
|
### 함정 5: columnName optional 변경
|
||||||
`groupByOpen` Popover에서 `columns` 배열을 사용하는데, 테이블 선택 직후 columns가 아직 로딩 중일 수 있음.
|
`CardFieldBinding.columnName`이 optional이 됨.
|
||||||
기존 코드에서 `loadingCols` 상태로 버튼을 disabled 처리하고 있으므로 문제없음.
|
기존에 `row[field.columnName]`으로 직접 접근하던 코드 전부 수정 필요.
|
||||||
|
`field.columnName ?? ""` 또는 valueType 분기 처리.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 검증 방법
|
## 6. 검증 방법
|
||||||
|
|
||||||
### 차트 (BUG-1, BUG-2)
|
### 시나리오 1: 기존 본문 필드 (하위 호환)
|
||||||
1. 아이템 추가 > "차트" 선택
|
1. 기존 저장된 카드리스트 열기
|
||||||
2. 테이블: `sales_order_mng`, 집계: COUNT, 컬럼: `id`, 그룹핑: `status`
|
2. 본문 필드에 기존 DB 컬럼 필드가 정상 표시되는지 확인
|
||||||
3. 차트 유형: 막대 차트
|
3. 설정 패널에서 기존 필드가 "DB 컬럼" 유형으로 표시되는지 확인
|
||||||
4. 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1
|
|
||||||
|
|
||||||
### 통계 카드 (BUG-3, BUG-4)
|
### 시나리오 2: 계산식 본문 필드 추가
|
||||||
1. 아이템 추가 > "통계 카드" 선택
|
1. 본문 필드 추가 -> 값 유형 "계산식" 선택
|
||||||
2. 테이블: `sales_order_mng`, **집계: 없음** (중요!)
|
2. 수식: `order_qty - received_qty` 입력
|
||||||
3. 카테고리 추가:
|
3. 카드에서 계산 결과가 정상 표시되는지 확인
|
||||||
- "수주" / status / = / 수주
|
|
||||||
- "진행중" / status / = / 진행중
|
### 시나리오 3: $input 참조 계산식
|
||||||
- "완료" / status / = / 완료
|
1. 입력 필드 활성화
|
||||||
4. 기대 결과: 수주 79, 진행중 7, 완료 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>
|
<details>
|
||||||
<summary>POP 뷰어 스크롤 수정 (완료)</summary>
|
<summary>POP 뷰어 스크롤 수정 (완료)</summary>
|
||||||
|
|
||||||
- [x] 라인 185: overflow-hidden 제거
|
- [x] overflow-hidden 제거
|
||||||
- [x] 라인 266: overflow-auto 공통 적용
|
- [x] overflow-auto 공통 적용
|
||||||
- [x] 라인 275: 일반 모드 min-h-full 추가
|
- [x] 일반 모드 min-h-full 추가
|
||||||
- [x] 린트 검사 통과
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
@ -521,28 +333,5 @@ ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `
|
||||||
|
|
||||||
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가
|
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가
|
||||||
- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체
|
- [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>
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
|
|
||||||
| 순서 | 작업 | 상태 |
|
| 순서 | 작업 | 상태 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 1 | 브라우저 확인 (라벨 정렬 동작, 반응형 자동 크기, 미리보기 반영) | [ ] 대기 |
|
| 1 | pop-card-list 입력 필드/계산 필드 구조 개편 (PLAN.MD 참고) | [ ] 코딩 대기 |
|
||||||
| 2 | 게이지/통계카드 테스트 시나리오 동작 확인 | [ ] 대기 |
|
| 2 | pop-card-list 담기 버튼 독립화 (보류) | [ ] 대기 |
|
||||||
| 3 | Phase 2 계획 수립 (pop-button, pop-icon) | [ ] 대기 |
|
| 3 | pop-card-list 반응형 표시 런타임 적용 | [ ] 대기 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ services:
|
||||||
- "9771:3000"
|
- "9771:3000"
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
||||||
|
- SERVER_API_URL=http://pms-backend-mac:8080
|
||||||
- NODE_OPTIONS=--max-old-space-size=8192
|
- NODE_OPTIONS=--max-old-space-size=8192
|
||||||
- NEXT_TELEMETRY_DISABLED=1
|
- NEXT_TELEMETRY_DISABLED=1
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,8 @@ export default function ComponentEditorPanel({
|
||||||
previewPageIndex={previewPageIndex}
|
previewPageIndex={previewPageIndex}
|
||||||
onPreviewPage={onPreviewPage}
|
onPreviewPage={onPreviewPage}
|
||||||
modals={modals}
|
modals={modals}
|
||||||
|
allComponents={allComponents}
|
||||||
|
connections={connections}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -404,9 +406,11 @@ interface ComponentSettingsFormProps {
|
||||||
previewPageIndex?: number;
|
previewPageIndex?: number;
|
||||||
onPreviewPage?: (pageIndex: number) => void;
|
onPreviewPage?: (pageIndex: number) => void;
|
||||||
modals?: PopModalDefinition[];
|
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 가져오기
|
// PopComponentRegistry에서 configPanel 가져오기
|
||||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||||
const ConfigPanel = registeredComp?.configPanel;
|
const ConfigPanel = registeredComp?.configPanel;
|
||||||
|
|
@ -440,6 +444,9 @@ function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIn
|
||||||
onPreviewPage={onPreviewPage}
|
onPreviewPage={onPreviewPage}
|
||||||
previewPageIndex={previewPageIndex}
|
previewPageIndex={previewPageIndex}
|
||||||
modals={modals}
|
modals={modals}
|
||||||
|
allComponents={allComponents}
|
||||||
|
connections={connections}
|
||||||
|
componentId={component.id}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg bg-gray-50 p-3">
|
<div className="rounded-lg bg-gray-50 p-3">
|
||||||
|
|
@ -515,3 +522,4 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,25 @@ function ConnectionForm({
|
||||||
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
||||||
: null;
|
: 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(
|
const displayColumns = React.useMemo(
|
||||||
() => extractDisplayColumns(targetComp || undefined),
|
() => extractDisplayColumns(targetComp || undefined),
|
||||||
|
|
@ -322,6 +341,8 @@ function ConnectionForm({
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
|
if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
|
||||||
|
|
||||||
|
const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput);
|
||||||
|
|
||||||
onSubmit({
|
onSubmit({
|
||||||
sourceComponent: component.id,
|
sourceComponent: component.id,
|
||||||
sourceField: "",
|
sourceField: "",
|
||||||
|
|
@ -330,7 +351,7 @@ function ConnectionForm({
|
||||||
targetField: "",
|
targetField: "",
|
||||||
targetInput: selectedTargetInput,
|
targetInput: selectedTargetInput,
|
||||||
filterConfig:
|
filterConfig:
|
||||||
filterColumns.length > 0
|
!isEvent && filterColumns.length > 0
|
||||||
? {
|
? {
|
||||||
targetColumn: filterColumns[0],
|
targetColumn: filterColumns[0],
|
||||||
targetColumns: filterColumns,
|
targetColumns: filterColumns,
|
||||||
|
|
@ -427,8 +448,8 @@ function ConnectionForm({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 필터 설정 */}
|
{/* 필터 설정: event 타입 연결이면 숨김 */}
|
||||||
{selectedTargetInput && (
|
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
|
||||||
<div className="space-y-2 rounded bg-gray-50 p-2">
|
<div className="space-y-2 rounded bg-gray-50 p-2">
|
||||||
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
<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(
|
function buildConnectionLabel(
|
||||||
source: PopComponentDefinitionV5,
|
source: PopComponentDefinitionV5,
|
||||||
_outputKey: string,
|
_outputKey: string,
|
||||||
|
|
|
||||||
|
|
@ -22,5 +22,9 @@ export type { PendingConfirmState } from "./usePopAction";
|
||||||
// 연결 해석기
|
// 연결 해석기
|
||||||
export { useConnectionResolver } from "./useConnectionResolver";
|
export { useConnectionResolver } from "./useConnectionResolver";
|
||||||
|
|
||||||
|
// 장바구니 동기화 훅
|
||||||
|
export { useCartSync } from "./useCartSync";
|
||||||
|
export type { UseCartSyncReturn } from "./useCartSync";
|
||||||
|
|
||||||
// SQL 빌더 유틸 (고급 사용 시)
|
// SQL 빌더 유틸 (고급 사용 시)
|
||||||
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import React, { useCallback, useState, useEffect, useMemo } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -24,8 +24,10 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||||
|
import { DataFlowAPI } from "@/lib/api/dataflow";
|
||||||
import { usePopAction } from "@/hooks/pop/usePopAction";
|
import { usePopAction } from "@/hooks/pop/usePopAction";
|
||||||
import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
|
import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
|
||||||
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||||
import {
|
import {
|
||||||
Save,
|
Save,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
|
@ -44,6 +46,8 @@ import {
|
||||||
Copy,
|
Copy,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ShoppingCart,
|
||||||
|
ShoppingBag,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -113,18 +117,30 @@ export type ButtonPreset =
|
||||||
| "logout"
|
| "logout"
|
||||||
| "menu"
|
| "menu"
|
||||||
| "modal-open"
|
| "modal-open"
|
||||||
|
| "cart"
|
||||||
| "custom";
|
| "custom";
|
||||||
|
|
||||||
|
/** row_data 저장 모드 */
|
||||||
|
export type RowDataMode = "all" | "selected";
|
||||||
|
|
||||||
|
/** 장바구니 버튼 전용 설정 */
|
||||||
|
export interface CartButtonConfig {
|
||||||
|
cartScreenId?: string;
|
||||||
|
rowDataMode?: RowDataMode;
|
||||||
|
selectedColumns?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** pop-button 전체 설정 */
|
/** pop-button 전체 설정 */
|
||||||
export interface PopButtonConfig {
|
export interface PopButtonConfig {
|
||||||
label: string;
|
label: string;
|
||||||
variant: ButtonVariant;
|
variant: ButtonVariant;
|
||||||
icon?: string; // Lucide 아이콘 이름
|
icon?: string;
|
||||||
iconOnly?: boolean;
|
iconOnly?: boolean;
|
||||||
preset: ButtonPreset;
|
preset: ButtonPreset;
|
||||||
confirm?: ConfirmConfig;
|
confirm?: ConfirmConfig;
|
||||||
action: ButtonMainAction;
|
action: ButtonMainAction;
|
||||||
followUpActions?: FollowUpAction[];
|
followUpActions?: FollowUpAction[];
|
||||||
|
cart?: CartButtonConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -163,6 +179,7 @@ const PRESET_LABELS: Record<ButtonPreset, string> = {
|
||||||
logout: "로그아웃",
|
logout: "로그아웃",
|
||||||
menu: "메뉴 (드롭다운)",
|
menu: "메뉴 (드롭다운)",
|
||||||
"modal-open": "모달 열기",
|
"modal-open": "모달 열기",
|
||||||
|
cart: "장바구니 저장",
|
||||||
custom: "직접 설정",
|
custom: "직접 설정",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -201,6 +218,8 @@ const ICON_OPTIONS: { value: string; label: string }[] = [
|
||||||
{ value: "Copy", label: "복사 (Copy)" },
|
{ value: "Copy", label: "복사 (Copy)" },
|
||||||
{ value: "Settings", label: "설정 (Settings)" },
|
{ value: "Settings", label: "설정 (Settings)" },
|
||||||
{ value: "ChevronDown", label: "아래 화살표 (ChevronDown)" },
|
{ 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 },
|
confirm: { enabled: false },
|
||||||
action: { type: "modal", modalMode: "fullscreen" },
|
action: { type: "modal", modalMode: "fullscreen" },
|
||||||
},
|
},
|
||||||
|
cart: {
|
||||||
|
label: "장바구니 저장",
|
||||||
|
variant: "default",
|
||||||
|
icon: "ShoppingCart",
|
||||||
|
confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" },
|
||||||
|
action: { type: "event" },
|
||||||
|
},
|
||||||
custom: {
|
custom: {
|
||||||
label: "버튼",
|
label: "버튼",
|
||||||
variant: "default",
|
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">→</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로 트리 쉐이킹 적용) */
|
/** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */
|
||||||
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
Save, Trash2, LogOut, Menu, ExternalLink, Plus, Check, X,
|
Save, Trash2, LogOut, Menu, ExternalLink, Plus, Check, X,
|
||||||
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
|
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
|
||||||
|
ShoppingCart,
|
||||||
|
ShoppingBag,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Lucide 아이콘 동적 렌더링 */
|
/** Lucide 아이콘 동적 렌더링 */
|
||||||
|
|
@ -309,6 +367,7 @@ interface PopButtonComponentProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
isDesignMode?: boolean;
|
isDesignMode?: boolean;
|
||||||
screenId?: string;
|
screenId?: string;
|
||||||
|
componentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PopButtonComponent({
|
export function PopButtonComponent({
|
||||||
|
|
@ -316,8 +375,8 @@ export function PopButtonComponent({
|
||||||
label,
|
label,
|
||||||
isDesignMode,
|
isDesignMode,
|
||||||
screenId,
|
screenId,
|
||||||
|
componentId,
|
||||||
}: PopButtonComponentProps) {
|
}: PopButtonComponentProps) {
|
||||||
// usePopAction 훅으로 액션 실행 통합
|
|
||||||
const {
|
const {
|
||||||
execute,
|
execute,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -326,23 +385,127 @@ export function PopButtonComponent({
|
||||||
cancelConfirm,
|
cancelConfirm,
|
||||||
} = usePopAction(screenId || "");
|
} = 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 => {
|
const getConfirmMessage = useCallback((): string => {
|
||||||
if (pendingConfirm?.confirm?.message) return pendingConfirm.confirm.message;
|
if (pendingConfirm?.confirm?.message) return pendingConfirm.confirm.message;
|
||||||
if (config?.confirm?.message) return config.confirm.message;
|
if (config?.confirm?.message) return config.confirm.message;
|
||||||
return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"];
|
return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"];
|
||||||
}, [pendingConfirm?.confirm?.message, config?.confirm?.message, config?.action?.type]);
|
}, [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 () => {
|
const handleClick = useCallback(async () => {
|
||||||
// 디자인 모드: 실제 실행 안 함
|
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
toast.info(
|
toast.info(
|
||||||
`[디자인 모드] ${ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
|
`[디자인 모드] ${isCartMode ? "장바구니 저장" : ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
|
||||||
);
|
);
|
||||||
return;
|
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;
|
const action = config?.action;
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
|
|
||||||
|
|
@ -350,7 +513,7 @@ export function PopButtonComponent({
|
||||||
confirm: config?.confirm,
|
confirm: config?.confirm,
|
||||||
followUpActions: config?.followUpActions,
|
followUpActions: config?.followUpActions,
|
||||||
});
|
});
|
||||||
}, [isDesignMode, config?.action, config?.confirm, config?.followUpActions, execute]);
|
}, [isDesignMode, isCartMode, config, cartCount, cartIsDirty, execute, handleCartSave]);
|
||||||
|
|
||||||
// 외형
|
// 외형
|
||||||
const buttonLabel = config?.label || label || "버튼";
|
const buttonLabel = config?.label || label || "버튼";
|
||||||
|
|
@ -358,30 +521,96 @@ export function PopButtonComponent({
|
||||||
const iconName = config?.icon || "";
|
const iconName = config?.icon || "";
|
||||||
const isIconOnly = config?.iconOnly || false;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<Button
|
<div className="relative">
|
||||||
variant={variant}
|
<Button
|
||||||
onClick={handleClick}
|
variant={variant}
|
||||||
disabled={isLoading}
|
onClick={handleClick}
|
||||||
className={cn(
|
disabled={isLoading || cartSaving}
|
||||||
"transition-transform active:scale-95",
|
className={cn(
|
||||||
isIconOnly && "px-2"
|
"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>
|
||||||
)}
|
)}
|
||||||
>
|
</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(); }}>
|
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
|
||||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|
@ -420,14 +649,117 @@ export function PopButtonComponent({
|
||||||
interface PopButtonConfigPanelProps {
|
interface PopButtonConfigPanelProps {
|
||||||
config: PopButtonConfig;
|
config: PopButtonConfig;
|
||||||
onUpdate: (config: PopButtonConfig) => void;
|
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({
|
export function PopButtonConfigPanel({
|
||||||
config,
|
config,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
allComponents,
|
||||||
|
connections,
|
||||||
|
componentId,
|
||||||
}: PopButtonConfigPanelProps) {
|
}: PopButtonConfigPanelProps) {
|
||||||
const isCustom = config?.preset === "custom";
|
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 handlePresetChange = (preset: ButtonPreset) => {
|
||||||
const defaults = PRESET_DEFAULTS[preset];
|
const defaults = PRESET_DEFAULTS[preset];
|
||||||
|
|
@ -554,44 +886,203 @@ export function PopButtonConfigPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 액션 */}
|
{/* 장바구니 설정 (cart 프리셋 전용) */}
|
||||||
<SectionDivider label="메인 액션" />
|
{config?.preset === "cart" && (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
{/* 액션 타입 */}
|
<SectionDivider label="장바구니 설정" />
|
||||||
<div>
|
<div className="space-y-3">
|
||||||
<Label className="text-xs">액션 유형</Label>
|
<div>
|
||||||
<Select
|
<Label className="text-xs">장바구니 화면 ID</Label>
|
||||||
value={config?.action?.type || "save"}
|
<Input
|
||||||
onValueChange={(v) =>
|
value={config?.cart?.cartScreenId || ""}
|
||||||
updateAction({ type: v as ButtonActionType })
|
onChange={(e) =>
|
||||||
}
|
onUpdate({
|
||||||
disabled={!isCustom}
|
...config,
|
||||||
>
|
cart: { ...config.cart, cartScreenId: e.target.value },
|
||||||
<SelectTrigger className="h-8 text-xs">
|
})
|
||||||
<SelectValue />
|
}
|
||||||
</SelectTrigger>
|
placeholder="저장 후 이동할 POP 화면 ID"
|
||||||
<SelectContent>
|
className="h-8 text-xs"
|
||||||
{Object.entries(ACTION_TYPE_LABELS).map(([key, label]) => (
|
/>
|
||||||
<SelectItem key={key} value={key} className="text-xs">
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
{label}
|
저장 완료 후 이동할 장바구니 리스트 화면 ID입니다.
|
||||||
</SelectItem>
|
비어있으면 이동 없이 저장만 합니다.
|
||||||
))}
|
</p>
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
</div>
|
||||||
{!isCustom && (
|
|
||||||
<p className="text-[10px] text-muted-foreground mt-1">
|
|
||||||
프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션별 추가 설정 */}
|
{/* 데이터 저장 흐름 시각화 */}
|
||||||
<ActionDetailFields
|
<SectionDivider label="데이터 저장 흐름" />
|
||||||
action={config?.action}
|
<div className="space-y-2">
|
||||||
onUpdate={updateAction}
|
<p className="text-muted-foreground text-[10px]">
|
||||||
disabled={!isCustom}
|
카드 목록에서 "담기" 클릭 시 아래와 같이 <code className="rounded bg-muted px-1 font-mono text-foreground">cart_items</code> 테이블에 저장됩니다.
|
||||||
/>
|
</p>
|
||||||
</div>
|
|
||||||
|
<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">
|
||||||
|
프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ActionDetailFields
|
||||||
|
action={config?.action}
|
||||||
|
onUpdate={updateAction}
|
||||||
|
disabled={!isCustom}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 확인 다이얼로그 */}
|
{/* 확인 다이얼로그 */}
|
||||||
<SectionDivider label="확인 메시지" />
|
<SectionDivider label="확인 메시지" />
|
||||||
|
|
@ -980,7 +1471,7 @@ function PopButtonPreviewComponent({
|
||||||
PopComponentRegistry.registerComponent({
|
PopComponentRegistry.registerComponent({
|
||||||
id: "pop-button",
|
id: "pop-button",
|
||||||
name: "버튼",
|
name: "버튼",
|
||||||
description: "액션 버튼 (저장/삭제/API/모달/이벤트)",
|
description: "액션 버튼 (저장/삭제/API/모달/이벤트/장바구니)",
|
||||||
category: "action",
|
category: "action",
|
||||||
icon: "MousePointerClick",
|
icon: "MousePointerClick",
|
||||||
component: PopButtonComponent,
|
component: PopButtonComponent,
|
||||||
|
|
@ -993,6 +1484,15 @@ PopComponentRegistry.registerComponent({
|
||||||
confirm: { enabled: false },
|
confirm: { enabled: false },
|
||||||
action: { type: "save" },
|
action: { type: "save" },
|
||||||
} as PopButtonConfig,
|
} 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,
|
touchOptimized: true,
|
||||||
supportedDevices: ["mobile", "tablet"],
|
supportedDevices: ["mobile", "tablet"],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,26 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { Delete } from "lucide-react";
|
import { Delete, Trash2, Plus, ArrowLeft } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import {
|
import {
|
||||||
PackageUnitModal,
|
PackageUnitModal,
|
||||||
PACKAGE_UNITS,
|
PACKAGE_UNITS,
|
||||||
type PackageUnit,
|
|
||||||
} from "./PackageUnitModal";
|
} from "./PackageUnitModal";
|
||||||
|
import type { CardPackageConfig, PackageEntry } from "../types";
|
||||||
|
|
||||||
|
type InputStep =
|
||||||
|
| "quantity" // 기본: 직접 수량 입력 (포장 OFF)
|
||||||
|
| "package_count" // 포장: 포장 수량 (N개)
|
||||||
|
| "quantity_per_unit" // 포장: 개당 수량 (M EA)
|
||||||
|
| "summary"; // 포장: 결과 확인 + 추가/완료
|
||||||
|
|
||||||
interface NumberInputModalProps {
|
interface NumberInputModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -22,7 +30,10 @@ interface NumberInputModalProps {
|
||||||
initialPackageUnit?: string;
|
initialPackageUnit?: string;
|
||||||
min?: number;
|
min?: number;
|
||||||
maxValue?: 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({
|
export function NumberInputModal({
|
||||||
|
|
@ -33,51 +44,184 @@ export function NumberInputModal({
|
||||||
initialPackageUnit,
|
initialPackageUnit,
|
||||||
min = 0,
|
min = 0,
|
||||||
maxValue = 999999,
|
maxValue = 999999,
|
||||||
|
showPackageUnit,
|
||||||
|
packageConfig,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: NumberInputModalProps) {
|
}: NumberInputModalProps) {
|
||||||
const [displayValue, setDisplayValue] = useState("");
|
const [displayValue, setDisplayValue] = useState("");
|
||||||
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
|
const [step, setStep] = useState<InputStep>("quantity");
|
||||||
const [isPackageModalOpen, setIsPackageModalOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setDisplayValue(initialValue > 0 ? String(initialValue) : "");
|
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 handleNumberClick = (num: string) => {
|
||||||
const newStr = displayValue + num;
|
const newStr = displayValue + num;
|
||||||
const numericValue = parseInt(newStr, 10);
|
const numericValue = parseInt(newStr, 10);
|
||||||
setDisplayValue(numericValue > maxValue ? String(maxValue) : newStr);
|
setDisplayValue(numericValue > currentMax ? String(currentMax) : newStr);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackspace = () =>
|
const handleBackspace = () =>
|
||||||
setDisplayValue((prev) => prev.slice(0, -1));
|
setDisplayValue((prev) => prev.slice(0, -1));
|
||||||
const handleClear = () => setDisplayValue("");
|
const handleClear = () => setDisplayValue("");
|
||||||
const handleMax = () => setDisplayValue(String(maxValue));
|
const handleMax = () => setDisplayValue(String(currentMax));
|
||||||
|
|
||||||
|
// --- 확인 버튼: step에 따라 다르게 동작 ---
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
const numericValue = parseInt(displayValue, 10) || 0;
|
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);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePackageUnitSelect = (selected: PackageUnit) => {
|
const handleBack = () => {
|
||||||
setPackageUnit(selected);
|
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 guideMessage = useMemo(() => {
|
||||||
const packageUnitLabel = matchedUnit?.label ?? null;
|
switch (step) {
|
||||||
const packageUnitEmoji = matchedUnit?.emoji ?? "📦";
|
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
|
const displayText = displayValue
|
||||||
? parseInt(displayValue, 10).toLocaleString()
|
? parseInt(displayValue, 10).toLocaleString()
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
const isBackVisible = step !== "quantity";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
|
@ -86,112 +230,200 @@ export function NumberInputModal({
|
||||||
<DialogPrimitive.Content
|
<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"
|
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">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
최대 {maxValue.toLocaleString()} {unit}
|
{isBackVisible && (
|
||||||
</span>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={handleBack}
|
||||||
onClick={() => setIsPackageModalOpen(true)}
|
className="flex items-center justify-center rounded-full bg-white/20 p-1.5 text-white hover:bg-white/30 active:bg-white/40"
|
||||||
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"
|
>
|
||||||
>
|
<ArrowLeft className="h-4 w-4" />
|
||||||
{packageUnitEmoji} {packageUnitLabel ? `${packageUnitLabel} ✓` : "포장등록"}
|
</button>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="space-y-3 p-4">
|
<div className="space-y-3 p-4">
|
||||||
{/* 숫자 표시 영역 */}
|
{/* summary 단계: 포장 내역 리스트 */}
|
||||||
<div className="flex min-h-[72px] items-center justify-end rounded-xl border-2 border-gray-200 bg-gray-50 px-4 py-4">
|
{step === "summary" ? (
|
||||||
{displayText ? (
|
<div className="space-y-3">
|
||||||
<span className="text-4xl font-bold tracking-tight text-gray-900">
|
{/* 안내 메시지 - 마지막 등록 결과 */}
|
||||||
{displayText}
|
{showSummary && entries.length > 0 && (
|
||||||
</span>
|
<div className="rounded-lg bg-blue-50 px-3 py-2 text-center text-sm font-medium text-blue-700">
|
||||||
) : (
|
{(() => {
|
||||||
<span className="text-2xl text-gray-300">0</span>
|
const last = entries[entries.length - 1];
|
||||||
)}
|
return `${last.packageCount}${last.unitLabel} x ${last.quantityPerUnit}${unit} = ${last.totalQuantity.toLocaleString()}${unit}`;
|
||||||
</div>
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 안내 텍스트 */}
|
{/* 포장 내역 리스트 */}
|
||||||
<p className="text-muted-foreground text-center text-sm">
|
<div className="space-y-1.5">
|
||||||
수량을 입력하세요
|
<p className="text-xs font-medium text-gray-500">포장 내역</p>
|
||||||
</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">
|
<div className="flex items-center justify-between rounded-lg bg-green-50 px-3 py-2">
|
||||||
{/* 1행: 7 8 9 ← (주황) */}
|
<span className="text-sm font-medium text-green-700">합계</span>
|
||||||
{["7", "8", "9"].map((n) => (
|
<span className="text-lg font-bold text-green-700">
|
||||||
<button
|
{entriesTotal.toLocaleString()} {unit}
|
||||||
key={n}
|
</span>
|
||||||
type="button"
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* 2행: 4 5 6 C (주황) */}
|
{/* 남은 수량 */}
|
||||||
{["4", "5", "6"].map((n) => (
|
{remainingQuantity > 0 && (
|
||||||
<button
|
<div className="flex items-center justify-between rounded-lg bg-amber-50 px-3 py-2">
|
||||||
key={n}
|
<span className="text-sm font-medium text-amber-700">남은 수량</span>
|
||||||
type="button"
|
<span className="text-sm font-bold text-amber-700">
|
||||||
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
{remainingQuantity.toLocaleString()} {unit}
|
||||||
onClick={() => handleNumberClick(n)}
|
</span>
|
||||||
>
|
</div>
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* 3행: 1 2 3 MAX (파란) */}
|
{/* 액션 버튼 */}
|
||||||
{["1", "2", "3"].map((n) => (
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
{remainingQuantity > 0 && (
|
||||||
key={n}
|
<button
|
||||||
type="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"
|
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={() => handleNumberClick(n)}
|
onClick={handleAddMore}
|
||||||
>
|
>
|
||||||
{n}
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
추가 포장
|
||||||
))}
|
</button>
|
||||||
<button
|
)}
|
||||||
type="button"
|
<button
|
||||||
className="h-14 rounded-2xl bg-blue-100 text-sm font-bold text-blue-600 active:bg-blue-200"
|
type="button"
|
||||||
onClick={handleMax}
|
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}
|
||||||
MAX
|
>
|
||||||
</button>
|
완료
|
||||||
|
</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
|
<p className="text-muted-foreground text-center text-sm">
|
||||||
type="button"
|
{guideMessage}
|
||||||
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
</p>
|
||||||
onClick={() => handleNumberClick("0")}
|
|
||||||
>
|
{/* 키패드 4x4 */}
|
||||||
0
|
<div className="grid grid-cols-4 gap-2">
|
||||||
</button>
|
{["7", "8", "9"].map((n) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
key={n}
|
||||||
className="col-span-3 h-14 rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600"
|
type="button"
|
||||||
onClick={handleConfirm}
|
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)}
|
||||||
확인
|
>
|
||||||
</button>
|
{n}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
|
|
@ -201,8 +433,18 @@ export function NumberInputModal({
|
||||||
{/* 포장 단위 선택 모달 */}
|
{/* 포장 단위 선택 모달 */}
|
||||||
<PackageUnitModal
|
<PackageUnitModal
|
||||||
open={isPackageModalOpen}
|
open={isPackageModalOpen}
|
||||||
onOpenChange={setIsPackageModalOpen}
|
onOpenChange={(isOpen) => {
|
||||||
onSelect={handlePackageUnitSelect}
|
setIsPackageModalOpen(isOpen);
|
||||||
|
if (!isOpen && step === "summary") {
|
||||||
|
// summary에서 추가 포장 모달 닫힘 -> 단위 선택 안 한 경우 유지
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSelect={(unitId) => {
|
||||||
|
handlePackageUnitSelect(unitId);
|
||||||
|
setIsPackageModalOpen(false);
|
||||||
|
}}
|
||||||
|
enabledUnits={packageConfig?.enabledUnits}
|
||||||
|
customUnits={packageConfig?.customUnits}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import type { CustomPackageUnit } from "../types";
|
||||||
|
|
||||||
export const PACKAGE_UNITS = [
|
export const PACKAGE_UNITS = [
|
||||||
{ value: "box", label: "박스", emoji: "📦" },
|
{ value: "box", label: "박스", emoji: "📦" },
|
||||||
|
|
@ -24,19 +27,33 @@ export type PackageUnit = (typeof PACKAGE_UNITS)[number]["value"];
|
||||||
interface PackageUnitModalProps {
|
interface PackageUnitModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSelect: (unit: PackageUnit) => void;
|
onSelect: (unit: string) => void;
|
||||||
|
enabledUnits?: string[];
|
||||||
|
customUnits?: CustomPackageUnit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PackageUnitModal({
|
export function PackageUnitModal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
enabledUnits,
|
||||||
|
customUnits,
|
||||||
}: PackageUnitModalProps) {
|
}: PackageUnitModalProps) {
|
||||||
const handleSelect = (unit: PackageUnit) => {
|
const handleSelect = (unitValue: string) => {
|
||||||
onSelect(unit);
|
onSelect(unitValue);
|
||||||
onOpenChange(false);
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
|
|
@ -45,18 +62,17 @@ export function PackageUnitModal({
|
||||||
<DialogPrimitive.Content
|
<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]"
|
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">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 3x2 그리드 */}
|
|
||||||
<div className="grid grid-cols-3 gap-3 p-4">
|
<div className="grid grid-cols-3 gap-3 p-4">
|
||||||
{PACKAGE_UNITS.map((unit) => (
|
{allUnits.map((unit) => (
|
||||||
<button
|
<button
|
||||||
key={unit.value}
|
key={unit.value}
|
||||||
type="button"
|
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"
|
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>
|
<span className="text-2xl">{unit.emoji}</span>
|
||||||
|
|
@ -65,7 +81,12 @@ export function PackageUnitModal({
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,11 @@
|
||||||
|
|
||||||
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X } from "lucide-react";
|
import {
|
||||||
import * as LucideIcons from "lucide-react";
|
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight,
|
||||||
|
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -20,10 +23,11 @@ import type {
|
||||||
CardTemplateConfig,
|
CardTemplateConfig,
|
||||||
CardFieldBinding,
|
CardFieldBinding,
|
||||||
CardInputFieldConfig,
|
CardInputFieldConfig,
|
||||||
CardCalculatedFieldConfig,
|
|
||||||
CardCartActionConfig,
|
CardCartActionConfig,
|
||||||
|
CardPackageConfig,
|
||||||
CardPresetSpec,
|
CardPresetSpec,
|
||||||
CartItem,
|
CartItem,
|
||||||
|
PackageEntry,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CARD_IMAGE,
|
DEFAULT_CARD_IMAGE,
|
||||||
|
|
@ -31,13 +35,16 @@ import {
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||||
|
import { useCartSync } from "@/hooks/pop/useCartSync";
|
||||||
import { NumberInputModal } from "./NumberInputModal";
|
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 }) {
|
function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
|
||||||
if (!name) return <ShoppingCart size={size} />;
|
if (!name) return <ShoppingCart size={size} />;
|
||||||
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
|
const IconComp = LUCIDE_ICON_MAP[name];
|
||||||
const IconComp = icons[name];
|
|
||||||
if (!IconComp) return <ShoppingCart size={size} />;
|
if (!IconComp) return <ShoppingCart size={size} />;
|
||||||
return <IconComp size={size} />;
|
return <IconComp size={size} />;
|
||||||
}
|
}
|
||||||
|
|
@ -157,25 +164,92 @@ export function PopCardListComponent({
|
||||||
const dataSource = config?.dataSource;
|
const dataSource = config?.dataSource;
|
||||||
const template = config?.cardTemplate;
|
const template = config?.cardTemplate;
|
||||||
|
|
||||||
// 이벤트 기반 company_code 필터링
|
const { subscribe, publish } = usePopEvent(screenId || "default");
|
||||||
const [eventCompanyCode, setEventCompanyCode] = useState<string | undefined>();
|
|
||||||
const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default");
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
// 장바구니 DB 동기화
|
||||||
if (!screenId) return;
|
const sourceTableName = dataSource?.tableName || "";
|
||||||
const unsub = subscribe("company_selected", (payload: unknown) => {
|
const cartType = config?.cartAction?.cartType;
|
||||||
const p = payload as { companyCode?: string } | undefined;
|
const cart = useCartSync(screenId || "", sourceTableName, cartType);
|
||||||
setEventCompanyCode(p?.companyCode);
|
|
||||||
});
|
|
||||||
return unsub;
|
|
||||||
}, [screenId, subscribe]);
|
|
||||||
|
|
||||||
// 데이터 상태
|
// 데이터 상태
|
||||||
const [rows, setRows] = useState<RowData[]>([]);
|
const [rows, setRows] = useState<RowData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
@ -190,7 +264,9 @@ export function PopCardListComponent({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const observer = new ResizeObserver((entries) => {
|
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 (width > 0) setContainerWidth(width);
|
||||||
if (height > 0) setContainerHeight(height);
|
if (height > 0) setContainerHeight(height);
|
||||||
});
|
});
|
||||||
|
|
@ -200,9 +276,10 @@ export function PopCardListComponent({
|
||||||
|
|
||||||
// 이미지 URL 없는 항목 카운트 (toast 중복 방지용)
|
// 이미지 URL 없는 항목 카운트 (toast 중복 방지용)
|
||||||
const missingImageCountRef = useRef(0);
|
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열
|
// 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열
|
||||||
const maxAllowedColumns = useMemo(() => {
|
const maxAllowedColumns = useMemo(() => {
|
||||||
|
|
@ -216,67 +293,82 @@ export function PopCardListComponent({
|
||||||
const autoColumns = containerWidth > 0
|
const autoColumns = containerWidth > 0
|
||||||
? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap)))
|
? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap)))
|
||||||
: maxGridColumns;
|
: maxGridColumns;
|
||||||
const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns);
|
const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns));
|
||||||
|
|
||||||
// 컨테이너 높이 기반 행 수 자동 계산 (잘림 방지)
|
// 행 수: 설정값 그대로 사용 (containerHeight 연동 시 피드백 루프 위험)
|
||||||
const effectiveGridRows = useMemo(() => {
|
const gridRows = configGridRows;
|
||||||
if (containerHeight <= 0) return configGridRows;
|
|
||||||
|
|
||||||
const controlBarHeight = 44;
|
// 카드 크기: 높이는 프리셋 고정, 너비만 컨테이너 기반 동적 계산
|
||||||
const effectiveHeight = baseContainerHeight.current > 0
|
// (높이를 containerHeight에 연동하면 뷰어 모드의 minmax(auto) 그리드와
|
||||||
? baseContainerHeight.current
|
// ResizeObserver 사이에서 피드백 루프가 발생해 무한 성장함)
|
||||||
: 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 기준으로 동적 계산
|
|
||||||
const scaled = useMemo((): ScaledConfig => {
|
const scaled = useMemo((): ScaledConfig => {
|
||||||
const gap = spec.gap;
|
const gap = spec.gap;
|
||||||
const controlBarHeight = 44;
|
|
||||||
|
|
||||||
const buildScaledConfig = (cardWidth: number, cardHeight: number): ScaledConfig => {
|
const cardHeight = spec.height;
|
||||||
const scale = cardHeight / spec.height;
|
const minCardWidth = Math.round(spec.height * 1.6);
|
||||||
return {
|
|
||||||
cardHeight,
|
const cardWidth = containerWidth > 0
|
||||||
cardWidth,
|
? Math.max(minCardWidth,
|
||||||
imageSize: Math.round(spec.imageSize * scale),
|
Math.floor((containerWidth - gap * (gridColumns - 1)) / gridColumns))
|
||||||
padding: Math.round(spec.padding * scale),
|
: minCardWidth;
|
||||||
gap,
|
|
||||||
headerPaddingX: Math.round(spec.headerPadX * scale),
|
return {
|
||||||
headerPaddingY: Math.round(spec.headerPadY * scale),
|
cardHeight,
|
||||||
codeTextSize: Math.round(spec.codeText * scale),
|
cardWidth,
|
||||||
titleTextSize: Math.round(spec.titleText * scale),
|
imageSize: spec.imageSize,
|
||||||
bodyTextSize: Math.round(spec.bodyText * scale),
|
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) {
|
const allFilters = [...externalFilters.values()];
|
||||||
return buildScaledConfig(Math.round(spec.height * 1.6), spec.height);
|
return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f)));
|
||||||
}
|
}, [rows, externalFilters]);
|
||||||
|
|
||||||
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 visibleCardCount = useMemo(() => {
|
const visibleCardCount = useMemo(() => {
|
||||||
|
|
@ -284,7 +376,7 @@ export function PopCardListComponent({
|
||||||
}, [gridColumns, gridRows]);
|
}, [gridColumns, gridRows]);
|
||||||
|
|
||||||
// 더보기 버튼 표시 여부
|
// 더보기 버튼 표시 여부
|
||||||
const hasMoreCards = rows.length > visibleCardCount;
|
const hasMoreCards = filteredRows.length > visibleCardCount;
|
||||||
|
|
||||||
// 확장 상태에서 표시할 카드 수 계산
|
// 확장 상태에서 표시할 카드 수 계산
|
||||||
const expandedCardsPerPage = useMemo(() => {
|
const expandedCardsPerPage = useMemo(() => {
|
||||||
|
|
@ -300,19 +392,17 @@ export function PopCardListComponent({
|
||||||
// 현재 표시할 카드 결정
|
// 현재 표시할 카드 결정
|
||||||
const displayCards = useMemo(() => {
|
const displayCards = useMemo(() => {
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
// 기본 상태: visibleCardCount만큼만 표시
|
return filteredRows.slice(0, visibleCardCount);
|
||||||
return rows.slice(0, visibleCardCount);
|
|
||||||
} else {
|
} else {
|
||||||
// 확장 상태: 현재 페이지의 카드들 (스크롤 가능하도록 더 많이)
|
|
||||||
const start = (currentPage - 1) * expandedCardsPerPage;
|
const start = (currentPage - 1) * expandedCardsPerPage;
|
||||||
const end = start + 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
|
const totalPages = isExpanded
|
||||||
? Math.ceil(rows.length / expandedCardsPerPage)
|
? Math.ceil(filteredRows.length / expandedCardsPerPage)
|
||||||
: 1;
|
: 1;
|
||||||
// 페이지네이션 필요: 확장 상태에서 전체 카드가 한 페이지를 초과할 때
|
// 페이지네이션 필요: 확장 상태에서 전체 카드가 한 페이지를 초과할 때
|
||||||
const needsPagination = isExpanded && totalPages > 1;
|
const needsPagination = isExpanded && totalPages > 1;
|
||||||
|
|
@ -358,7 +448,12 @@ export function PopCardListComponent({
|
||||||
}
|
}
|
||||||
}, [currentPage, isExpanded]);
|
}, [currentPage, isExpanded]);
|
||||||
|
|
||||||
// 데이터 조회
|
// dataSource를 직렬화해서 의존성 안정화 (객체 참조 변경에 의한 불필요한 재호출 방지)
|
||||||
|
const dataSourceKey = useMemo(
|
||||||
|
() => JSON.stringify(dataSource || null),
|
||||||
|
[dataSource]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dataSource?.tableName) {
|
if (!dataSource?.tableName) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -370,10 +465,8 @@ export function PopCardListComponent({
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
missingImageCountRef.current = 0;
|
missingImageCountRef.current = 0;
|
||||||
toastShownRef.current = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 필터 조건 구성
|
|
||||||
const filters: Record<string, unknown> = {};
|
const filters: Record<string, unknown> = {};
|
||||||
if (dataSource.filters && dataSource.filters.length > 0) {
|
if (dataSource.filters && dataSource.filters.length > 0) {
|
||||||
dataSource.filters.forEach((f) => {
|
dataSource.filters.forEach((f) => {
|
||||||
|
|
@ -383,28 +476,25 @@ export function PopCardListComponent({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이벤트로 수신한 company_code 필터 병합
|
// 다중 정렬: 첫 번째 기준을 서버 정렬로 전달 (하위 호환: 단일 객체도 처리)
|
||||||
if (eventCompanyCode) {
|
const sortArray = Array.isArray(dataSource.sort)
|
||||||
filters["company_code"] = eventCompanyCode;
|
? 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 =
|
const size =
|
||||||
dataSource.limit?.mode === "limited" && dataSource.limit?.count
|
dataSource.limit?.mode === "limited" && dataSource.limit?.count
|
||||||
? dataSource.limit.count
|
? dataSource.limit.count
|
||||||
: 100;
|
: 100;
|
||||||
|
|
||||||
// TODO: 조인 지원은 추후 구현
|
|
||||||
// 현재는 단일 테이블 조회만 지원
|
|
||||||
|
|
||||||
const result = await dataApi.getTableData(dataSource.tableName, {
|
const result = await dataApi.getTableData(dataSource.tableName, {
|
||||||
page: 1,
|
page: 1,
|
||||||
size,
|
size,
|
||||||
sortBy: sortOrder ? sortBy : undefined,
|
sortBy: sortBy || undefined,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -420,27 +510,13 @@ export function PopCardListComponent({
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [dataSource, eventCompanyCode]);
|
}, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 이미지 URL 없는 항목 체크 및 toast 표시
|
// 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (!loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn) {
|
||||||
!loading &&
|
|
||||||
rows.length > 0 &&
|
|
||||||
template?.image?.enabled &&
|
|
||||||
template?.image?.imageColumn &&
|
|
||||||
!toastShownRef.current
|
|
||||||
) {
|
|
||||||
const imageColumn = template.image.imageColumn;
|
const imageColumn = template.image.imageColumn;
|
||||||
const missingCount = rows.filter((row) => !row[imageColumn]).length;
|
missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length;
|
||||||
|
|
||||||
if (missingCount > 0) {
|
|
||||||
missingImageCountRef.current = missingCount;
|
|
||||||
toastShownRef.current = true;
|
|
||||||
toast.warning(
|
|
||||||
`${missingCount}개 항목의 이미지 URL이 없어 기본 이미지로 표시됩니다`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [loading, rows, template?.image]);
|
}, [loading, rows, template?.image]);
|
||||||
|
|
||||||
|
|
@ -503,21 +579,29 @@ export function PopCardListComponent({
|
||||||
justifyContent: isHorizontalMode ? "start" : "center",
|
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
|
<Card
|
||||||
key={index}
|
key={rowKey}
|
||||||
row={row}
|
row={row}
|
||||||
template={template}
|
template={template}
|
||||||
scaled={scaled}
|
scaled={scaled}
|
||||||
inputField={config?.inputField}
|
inputField={config?.inputField}
|
||||||
calculatedField={config?.calculatedField}
|
packageConfig={config?.packageConfig}
|
||||||
cartAction={config?.cartAction}
|
cartAction={config?.cartAction}
|
||||||
publish={publish}
|
publish={publish}
|
||||||
getSharedData={getSharedData}
|
|
||||||
setSharedData={setSharedData}
|
|
||||||
router={router}
|
router={router}
|
||||||
|
onSelect={handleCardSelect}
|
||||||
|
cart={cart}
|
||||||
|
codeFieldName={template?.header?.codeField}
|
||||||
|
parentComponentId={componentId}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하단 컨트롤 영역 */}
|
{/* 하단 컨트롤 영역 */}
|
||||||
|
|
@ -544,7 +628,7 @@ export function PopCardListComponent({
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{rows.length}건
|
{filteredRows.length}건{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}건` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -589,69 +673,75 @@ function Card({
|
||||||
template,
|
template,
|
||||||
scaled,
|
scaled,
|
||||||
inputField,
|
inputField,
|
||||||
calculatedField,
|
packageConfig,
|
||||||
cartAction,
|
cartAction,
|
||||||
publish,
|
publish,
|
||||||
getSharedData,
|
|
||||||
setSharedData,
|
|
||||||
router,
|
router,
|
||||||
|
onSelect,
|
||||||
|
cart,
|
||||||
|
codeFieldName,
|
||||||
|
parentComponentId,
|
||||||
}: {
|
}: {
|
||||||
row: RowData;
|
row: RowData;
|
||||||
template?: CardTemplateConfig;
|
template?: CardTemplateConfig;
|
||||||
scaled: ScaledConfig;
|
scaled: ScaledConfig;
|
||||||
inputField?: CardInputFieldConfig;
|
inputField?: CardInputFieldConfig;
|
||||||
calculatedField?: CardCalculatedFieldConfig;
|
packageConfig?: CardPackageConfig;
|
||||||
cartAction?: CardCartActionConfig;
|
cartAction?: CardCartActionConfig;
|
||||||
publish: (eventName: string, payload?: unknown) => void;
|
publish: (eventName: string, payload?: unknown) => void;
|
||||||
getSharedData: <T = unknown>(key: string) => T | undefined;
|
|
||||||
setSharedData: (key: string, value: unknown) => void;
|
|
||||||
router: ReturnType<typeof useRouter>;
|
router: ReturnType<typeof useRouter>;
|
||||||
|
onSelect?: (row: RowData) => void;
|
||||||
|
cart: ReturnType<typeof useCartSync>;
|
||||||
|
codeFieldName?: string;
|
||||||
|
parentComponentId?: string;
|
||||||
}) {
|
}) {
|
||||||
const header = template?.header;
|
const header = template?.header;
|
||||||
const image = template?.image;
|
const image = template?.image;
|
||||||
const body = template?.body;
|
const body = template?.body;
|
||||||
|
|
||||||
// 입력 필드 상태
|
const [inputValue, setInputValue] = useState<number>(0);
|
||||||
const [inputValue, setInputValue] = useState<number>(
|
|
||||||
inputField?.defaultValue || 0
|
|
||||||
);
|
|
||||||
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
|
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
|
||||||
|
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
// 담기/취소 토글 상태
|
|
||||||
const [isCarted, setIsCarted] = useState(false);
|
|
||||||
|
|
||||||
// 헤더 값 추출
|
|
||||||
const codeValue = header?.codeField ? row[header.codeField] : null;
|
const codeValue = header?.codeField ? row[header.codeField] : null;
|
||||||
const titleValue = header?.titleField ? row[header.titleField] : 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 =
|
const imageUrl =
|
||||||
image?.enabled && image?.imageColumn && row[image.imageColumn]
|
image?.enabled && image?.imageColumn && row[image.imageColumn]
|
||||||
? String(row[image.imageColumn])
|
? String(row[image.imageColumn])
|
||||||
: image?.defaultImage || DEFAULT_CARD_IMAGE;
|
: image?.defaultImage || DEFAULT_CARD_IMAGE;
|
||||||
|
|
||||||
// 계산 필드 값 계산
|
// limitColumn 우선, 하위 호환으로 maxColumn 폴백
|
||||||
const calculatedValue = useMemo(() => {
|
const limitCol = inputField?.limitColumn || inputField?.maxColumn;
|
||||||
if (!calculatedField?.enabled || !calculatedField?.formula) return null;
|
|
||||||
return evaluateFormula(calculatedField.formula, row, inputValue);
|
|
||||||
}, [calculatedField, row, inputValue]);
|
|
||||||
|
|
||||||
// effectiveMax: DB 컬럼 우선, 없으면 inputField.max 폴백
|
|
||||||
const effectiveMax = useMemo(() => {
|
const effectiveMax = useMemo(() => {
|
||||||
if (inputField?.maxColumn) {
|
if (limitCol) {
|
||||||
const colVal = Number(row[inputField.maxColumn]);
|
const colVal = Number(row[limitCol]);
|
||||||
if (!isNaN(colVal) && colVal > 0) return colVal;
|
if (!isNaN(colVal) && colVal > 0) return colVal;
|
||||||
}
|
}
|
||||||
return inputField?.max ?? 999999;
|
return 999999;
|
||||||
}, [inputField, row]);
|
}, [limitCol, row]);
|
||||||
|
|
||||||
// 기본값이 설정되지 않은 경우 최대값으로 자동 초기화
|
// 제한 컬럼이 있으면 최대값으로 자동 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputField?.enabled && !inputField?.defaultValue && effectiveMax > 0 && effectiveMax < 999999) {
|
if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
|
||||||
setInputValue(effectiveMax);
|
setInputValue(effectiveMax);
|
||||||
}
|
}
|
||||||
}, [effectiveMax, inputField?.enabled, inputField?.defaultValue]);
|
}, [effectiveMax, inputField?.enabled, limitCol]);
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
height: `${scaled.cardHeight}px`,
|
height: `${scaled.cardHeight}px`,
|
||||||
|
|
@ -677,43 +767,43 @@ function Card({
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputConfirm = (value: number, unit?: string) => {
|
const handleInputConfirm = (value: number, unit?: string, entries?: PackageEntry[]) => {
|
||||||
setInputValue(value);
|
setInputValue(value);
|
||||||
setPackageUnit(unit);
|
setPackageUnit(unit);
|
||||||
|
setPackageEntries(entries || []);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글
|
// 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달
|
||||||
const handleCartAdd = () => {
|
const handleCartAdd = () => {
|
||||||
|
if (!rowKey) return;
|
||||||
|
|
||||||
const cartItem: CartItem = {
|
const cartItem: CartItem = {
|
||||||
row,
|
row,
|
||||||
quantity: inputValue,
|
quantity: inputValue,
|
||||||
packageUnit: packageUnit || undefined,
|
packageUnit: packageUnit || undefined,
|
||||||
|
packageEntries: packageEntries.length > 0 ? packageEntries : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const existing = getSharedData<CartItem[]>("cart_items") || [];
|
cart.addItem(cartItem, rowKey);
|
||||||
setSharedData("cart_items", [...existing, cartItem]);
|
if (parentComponentId) {
|
||||||
publish("cart_item_added", cartItem);
|
publish(`__comp_output__${parentComponentId}__cart_updated`, {
|
||||||
|
count: cart.cartCount + 1,
|
||||||
setIsCarted(true);
|
isDirty: true,
|
||||||
toast.success("장바구니에 담겼습니다.");
|
});
|
||||||
|
|
||||||
if (cartAction?.navigateMode === "screen" && cartAction.targetScreenId) {
|
|
||||||
router.push(`/pop/screens/${cartAction.targetScreenId}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 취소: sharedData에서 해당 아이템 제거 + 이벤트 발행 + 토글 복원
|
// 취소: 로컬 상태에서만 제거 + 연결 시스템으로 카운트 전달
|
||||||
const handleCartCancel = () => {
|
const handleCartCancel = () => {
|
||||||
const existing = getSharedData<CartItem[]>("cart_items") || [];
|
if (!rowKey) return;
|
||||||
const rowKey = JSON.stringify(row);
|
|
||||||
const filtered = existing.filter(
|
|
||||||
(item) => JSON.stringify(item.row) !== rowKey
|
|
||||||
);
|
|
||||||
setSharedData("cart_items", filtered);
|
|
||||||
publish("cart_item_removed", { row });
|
|
||||||
|
|
||||||
setIsCarted(false);
|
cart.removeItem(rowKey);
|
||||||
toast.info("장바구니에서 제거되었습니다.");
|
if (parentComponentId) {
|
||||||
|
publish(`__comp_output__${parentComponentId}__cart_updated`, {
|
||||||
|
count: Math.max(0, cart.cartCount - 1),
|
||||||
|
isDirty: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영)
|
// pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영)
|
||||||
|
|
@ -721,14 +811,26 @@ function Card({
|
||||||
const cartLabel = cartAction?.label || "담기";
|
const cartLabel = cartAction?.label || "담기";
|
||||||
const cancelLabel = cartAction?.cancelLabel || "취소";
|
const cancelLabel = cartAction?.cancelLabel || "취소";
|
||||||
|
|
||||||
|
const handleCardClick = () => {
|
||||||
|
onSelect?.(row);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
style={cardStyle}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }}
|
||||||
>
|
>
|
||||||
{/* 헤더 영역 */}
|
{/* 헤더 영역 */}
|
||||||
{(codeValue !== null || titleValue !== null) && (
|
{(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">
|
<div className="flex items-center gap-2">
|
||||||
{codeValue !== null && (
|
{codeValue !== null && (
|
||||||
<span
|
<span
|
||||||
|
|
@ -777,7 +879,7 @@ function Card({
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: `${Math.round(scaled.gap / 2)}px` }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: `${Math.round(scaled.gap / 2)}px` }}>
|
||||||
{body?.fields && body.fields.length > 0 ? (
|
{body?.fields && body.fields.length > 0 ? (
|
||||||
body.fields.map((field) => (
|
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
|
<div
|
||||||
|
|
@ -787,80 +889,67 @@ function Card({
|
||||||
본문 필드를 추가하세요
|
본문 필드를 추가하세요
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (inputField 활성화 시만) */}
|
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */}
|
||||||
{inputField?.enabled && (
|
{(inputField?.enabled || cartAction) && (
|
||||||
<div
|
<div
|
||||||
className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2"
|
className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2"
|
||||||
style={{ minWidth: "100px" }}
|
style={{ minWidth: "100px" }}
|
||||||
>
|
>
|
||||||
{/* 수량 버튼 */}
|
{/* 수량 버튼 (입력 필드 ON일 때만) */}
|
||||||
<button
|
{inputField?.enabled && (
|
||||||
type="button"
|
<button
|
||||||
onClick={handleInputClick}
|
type="button"
|
||||||
className="rounded-lg border-2 border-gray-300 bg-white px-2 py-1.5 text-center hover:border-primary active:bg-gray-50"
|
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 className="block text-lg font-bold leading-tight">
|
||||||
</span>
|
{inputValue.toLocaleString()}
|
||||||
<span className="text-muted-foreground block text-[12px]">
|
</span>
|
||||||
{inputField.unit || "EA"}
|
<span className="text-muted-foreground block text-[12px]">
|
||||||
</span>
|
{inputField.unit || "EA"}
|
||||||
</button>
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* pop-icon 스타일 담기/취소 토글 버튼 */}
|
{/* 담기/취소 버튼 (cartAction 존재 시 항상 표시) */}
|
||||||
{isCarted ? (
|
{cartAction && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
{isCarted ? (
|
||||||
onClick={handleCartCancel}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={handleCartCancel}
|
||||||
<X size={iconSize} />
|
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"
|
||||||
<span style={{ fontSize: `${Math.max(9, scaled.bodyTextSize - 2)}px` }} className="font-semibold leading-tight">
|
>
|
||||||
{cancelLabel}
|
<X size={iconSize} />
|
||||||
</span>
|
<span style={{ fontSize: `${Math.max(9, scaled.bodyTextSize - 2)}px` }} className="font-semibold leading-tight">
|
||||||
</button>
|
{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>
|
|
||||||
) : (
|
) : (
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 숫자 입력 모달 */}
|
|
||||||
{inputField?.enabled && (
|
{inputField?.enabled && (
|
||||||
<NumberInputModal
|
<NumberInputModal
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
|
|
@ -868,8 +957,9 @@ function Card({
|
||||||
unit={inputField.unit || "EA"}
|
unit={inputField.unit || "EA"}
|
||||||
initialValue={inputValue}
|
initialValue={inputValue}
|
||||||
initialPackageUnit={packageUnit}
|
initialPackageUnit={packageUnit}
|
||||||
min={inputField.min || 0}
|
|
||||||
maxValue={effectiveMax}
|
maxValue={effectiveMax}
|
||||||
|
packageConfig={packageConfig}
|
||||||
|
showPackageUnit={inputField.showPackageUnit}
|
||||||
onConfirm={handleInputConfirm}
|
onConfirm={handleInputConfirm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -883,14 +973,54 @@ function FieldRow({
|
||||||
field,
|
field,
|
||||||
row,
|
row,
|
||||||
scaled,
|
scaled,
|
||||||
|
inputValue,
|
||||||
}: {
|
}: {
|
||||||
field: CardFieldBinding;
|
field: CardFieldBinding;
|
||||||
row: RowData;
|
row: RowData;
|
||||||
scaled: ScaledConfig;
|
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));
|
const labelMinWidth = Math.round(50 * (scaled.bodyTextSize / 12));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -898,19 +1028,17 @@ function FieldRow({
|
||||||
className="flex items-baseline"
|
className="flex items-baseline"
|
||||||
style={{ gap: `${Math.round(scaled.gap / 2)}px`, fontSize: `${scaled.bodyTextSize}px` }}
|
style={{ gap: `${Math.round(scaled.gap / 2)}px`, fontSize: `${scaled.bodyTextSize}px` }}
|
||||||
>
|
>
|
||||||
{/* 라벨 */}
|
|
||||||
<span
|
<span
|
||||||
className="shrink-0 text-muted-foreground"
|
className="shrink-0 text-muted-foreground"
|
||||||
style={{ minWidth: `${labelMinWidth}px` }}
|
style={{ minWidth: `${labelMinWidth}px` }}
|
||||||
>
|
>
|
||||||
{field.label}
|
{field.label}
|
||||||
</span>
|
</span>
|
||||||
{/* 값 - 기본 검정색, textColor 설정 시 해당 색상 */}
|
|
||||||
<MarqueeText
|
<MarqueeText
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
style={{ color: field.textColor || "#000000" }}
|
style={{ color: field.textColor || (isFormula ? "#ea580c" : "#000000") }}
|
||||||
>
|
>
|
||||||
{formatValue(value)}
|
{displayValue}
|
||||||
</MarqueeText>
|
</MarqueeText>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -982,7 +1110,6 @@ function evaluateFormula(
|
||||||
// 안전한 계산 (기본 산술 연산만 허용)
|
// 안전한 계산 (기본 산술 연산만 허용)
|
||||||
// 허용: 숫자, +, -, *, /, (, ), 공백, 소수점
|
// 허용: 숫자, +, -, *, /, (, ), 공백, 소수점
|
||||||
if (!/^[\d\s+\-*/().]+$/.test(expression)) {
|
if (!/^[\d\s+\-*/().]+$/.test(expression)) {
|
||||||
console.warn("Invalid formula expression:", expression);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -995,7 +1122,7 @@ function evaluateFormula(
|
||||||
|
|
||||||
return Math.round(result * 100) / 100; // 소수점 2자리까지
|
return Math.round(result * 100) / 100; // 소수점 2자리까지
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Formula evaluation error:", error);
|
// 수식 평가 실패 시 null 반환
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -41,9 +41,7 @@ const defaultConfig: PopCardListConfig = {
|
||||||
gridRows: 2,
|
gridRows: 2,
|
||||||
// 담기 버튼 기본 설정
|
// 담기 버튼 기본 설정
|
||||||
cartAction: {
|
cartAction: {
|
||||||
navigateMode: "none",
|
saveMode: "cart",
|
||||||
iconType: "lucide",
|
|
||||||
iconValue: "ShoppingCart",
|
|
||||||
label: "담기",
|
label: "담기",
|
||||||
cancelLabel: "취소",
|
cancelLabel: "취소",
|
||||||
},
|
},
|
||||||
|
|
@ -60,6 +58,17 @@ PopComponentRegistry.registerComponent({
|
||||||
configPanel: PopCardListConfigPanel,
|
configPanel: PopCardListConfigPanel,
|
||||||
preview: PopCardListPreviewComponent,
|
preview: PopCardListPreviewComponent,
|
||||||
defaultProps: defaultConfig,
|
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,
|
touchOptimized: true,
|
||||||
supportedDevices: ["mobile", "tablet"],
|
supportedDevices: ["mobile", "tablet"],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,20 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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 {
|
import type {
|
||||||
PopSearchConfig,
|
PopSearchConfig,
|
||||||
SearchInputType,
|
SearchInputType,
|
||||||
|
|
@ -379,6 +392,7 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
|
||||||
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
|
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
|
||||||
const [tablesLoading, setTablesLoading] = useState(false);
|
const [tablesLoading, setTablesLoading] = useState(false);
|
||||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||||
|
const [openTableCombo, setOpenTableCombo] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -455,23 +469,62 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
|
||||||
테이블 목록 로딩...
|
테이블 목록 로딩...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
||||||
value={mc.tableName || undefined}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={(v) =>
|
<Button
|
||||||
updateModal({ tableName: v, displayColumns: [], searchColumns: [], displayField: "", valueField: "", columnLabels: undefined })
|
variant="outline"
|
||||||
}
|
role="combobox"
|
||||||
>
|
aria-expanded={openTableCombo}
|
||||||
<SelectTrigger className="h-8 text-xs">
|
className="h-8 w-full justify-between text-xs"
|
||||||
<SelectValue placeholder="테이블 선택" />
|
>
|
||||||
</SelectTrigger>
|
{mc.tableName
|
||||||
<SelectContent>
|
? tables.find((t) => t.tableName === mc.tableName)?.displayName || mc.tableName
|
||||||
{tables.map((t) => (
|
: "테이블 선택"}
|
||||||
<SelectItem key={t.tableName} value={t.tableName} className="text-xs">
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
{t.displayName || t.tableName}
|
</Button>
|
||||||
</SelectItem>
|
</PopoverTrigger>
|
||||||
))}
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
</SelectContent>
|
<Command>
|
||||||
</Select>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -364,11 +364,24 @@ export interface CardColumnFilter {
|
||||||
|
|
||||||
// ----- 본문 필드 바인딩 (라벨-값 쌍) -----
|
// ----- 본문 필드 바인딩 (라벨-값 쌍) -----
|
||||||
|
|
||||||
|
export type FieldValueType = "column" | "formula";
|
||||||
|
export type FormulaOperator = "+" | "-" | "*" | "/";
|
||||||
|
export type FormulaRightType = "column" | "input";
|
||||||
|
|
||||||
export interface CardFieldBinding {
|
export interface CardFieldBinding {
|
||||||
id: string;
|
id: string;
|
||||||
columnName: string; // DB 컬럼명
|
label: string;
|
||||||
label: string; // 표시 라벨 (예: "발주일")
|
valueType: FieldValueType;
|
||||||
textColor?: string; // 텍스트 색상 (예: "#ef4444" 빨간색)
|
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 {
|
export interface CardListDataSource {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
joins?: CardColumnJoin[];
|
joins?: CardColumnJoin[];
|
||||||
filters?: CardColumnFilter[];
|
filters?: CardColumnFilter[];
|
||||||
sort?: { column: string; direction: "asc" | "desc" };
|
sort?: CardSortConfig[];
|
||||||
limit?: { mode: "all" | "limited"; count?: number };
|
limit?: { mode: "all" | "limited"; count?: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,44 +455,84 @@ export const CARD_SCROLL_DIRECTION_LABELS: Record<CardScrollDirection, string> =
|
||||||
|
|
||||||
export interface CardInputFieldConfig {
|
export interface CardInputFieldConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
columnName?: string; // 입력값이 저장될 컬럼
|
unit?: string; // 단위 (예: "EA")
|
||||||
label?: string; // 표시 라벨 (예: "발주 수량")
|
limitColumn?: string; // 제한 기준 컬럼 (해당 행의 이 컬럼 값이 최대값)
|
||||||
unit?: string; // 단위 (예: "EA", "개")
|
saveTable?: string; // 저장 대상 테이블
|
||||||
defaultValue?: number; // 기본값
|
saveColumn?: string; // 저장 대상 컬럼
|
||||||
min?: number; // 최소값
|
/** @deprecated limitColumn 사용 */
|
||||||
max?: number; // 최대값
|
maxColumn?: string;
|
||||||
maxColumn?: string; // 최대값을 DB 컬럼에서 동적으로 가져올 컬럼명 (설정 시 row[maxColumn] 우선)
|
/** @deprecated 미사용, 하위 호환용 */
|
||||||
step?: number; // 증감 단위
|
label?: string;
|
||||||
|
/** @deprecated packageConfig로 이동, 하위 호환용 */
|
||||||
|
showPackageUnit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- 카드 내 계산 필드 설정 -----
|
// ----- 포장등록 설정 -----
|
||||||
|
|
||||||
export interface CardCalculatedFieldConfig {
|
export interface CustomPackageUnit {
|
||||||
enabled: boolean;
|
id: string;
|
||||||
label?: string; // 표시 라벨 (예: "미입고")
|
label: string; // 표시명 (예: "파렛트")
|
||||||
formula: string; // 계산식 (예: "order_qty - inbound_qty")
|
|
||||||
sourceColumns: string[]; // 계산에 사용되는 컬럼들
|
|
||||||
resultColumn?: string; // 결과를 저장할 컬럼 (선택)
|
|
||||||
unit?: string; // 단위 (예: "EA")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- 담기 버튼 데이터 구조 (추후 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 {
|
export interface CartItem {
|
||||||
row: Record<string, unknown>; // 카드 원본 행 데이터
|
row: Record<string, unknown>; // 카드 원본 행 데이터
|
||||||
quantity: number; // 입력 수량
|
quantity: number; // 입력 수량
|
||||||
packageUnit?: string; // 포장 단위 (box/bag/pack/bundle/roll/barrel)
|
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 스타일 + 장바구니 연동) -----
|
// ----- 담기 버튼 액션 설정 (pop-icon 스타일 + 장바구니 연동) -----
|
||||||
|
|
||||||
|
|
||||||
|
export type CartSaveMode = "cart" | "direct";
|
||||||
|
|
||||||
export interface CardCartActionConfig {
|
export interface CardCartActionConfig {
|
||||||
navigateMode: "none" | "screen"; // 담기 후 이동 모드
|
saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장
|
||||||
targetScreenId?: string; // 장바구니 POP 화면 ID (screen 모드)
|
cartType?: string; // 장바구니 구분값 (예: "purchase_inbound")
|
||||||
iconType?: "lucide" | "emoji"; // 아이콘 타입
|
label?: string; // 담기 라벨 (기본: "담기")
|
||||||
iconValue?: string; // Lucide 아이콘명 또는 이모지 값
|
cancelLabel?: string; // 취소 라벨 (기본: "취소")
|
||||||
label?: string; // 담기 라벨 (기본: "담기")
|
// 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호)
|
||||||
cancelLabel?: string; // 취소 라벨 (기본: "취소")
|
dataFields?: { sourceField: string; targetField?: string; label?: string }[];
|
||||||
|
// 하위 호환: 기존 필드 (사용하지 않지만 기존 데이터 보호)
|
||||||
|
navigateMode?: "none" | "screen";
|
||||||
|
targetScreenId?: string;
|
||||||
|
iconType?: "lucide" | "emoji";
|
||||||
|
iconValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- pop-card-list 전체 설정 -----
|
// ----- 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 {
|
export interface PopCardListConfig {
|
||||||
// 데이터 소스 (테이블 단위)
|
|
||||||
dataSource: CardListDataSource;
|
dataSource: CardListDataSource;
|
||||||
|
|
||||||
// 카드 템플릿 (헤더 + 이미지 + 본문)
|
|
||||||
cardTemplate: CardTemplateConfig;
|
cardTemplate: CardTemplateConfig;
|
||||||
|
|
||||||
// 스크롤 방향
|
|
||||||
scrollDirection: CardScrollDirection;
|
scrollDirection: CardScrollDirection;
|
||||||
cardsPerRow?: number; // deprecated, gridColumns 사용
|
cardsPerRow?: number; // deprecated, gridColumns 사용
|
||||||
cardSize: CardSize; // 프리셋 크기 (small/medium/large)
|
cardSize: CardSize;
|
||||||
|
|
||||||
// 그리드 배치 설정 (가로 x 세로)
|
gridColumns?: number;
|
||||||
gridColumns?: number; // 가로 카드 수 (기본값: 3)
|
gridRows?: number;
|
||||||
gridRows?: number; // 세로 카드 수 (기본값: 2)
|
|
||||||
|
|
||||||
// 확장 설정 (더보기 클릭 시 그리드 rowSpan 변경)
|
// 반응형 표시 설정
|
||||||
// expandedRowSpan 제거됨 - 항상 원래 크기의 2배로 확장
|
responsiveDisplay?: CardResponsiveConfig;
|
||||||
|
|
||||||
// 입력 필드 설정 (수량 입력 등)
|
|
||||||
inputField?: CardInputFieldConfig;
|
inputField?: CardInputFieldConfig;
|
||||||
|
packageConfig?: CardPackageConfig;
|
||||||
// 계산 필드 설정 (미입고 등 자동 계산)
|
|
||||||
calculatedField?: CardCalculatedFieldConfig;
|
|
||||||
|
|
||||||
// 담기 버튼 액션 설정 (pop-icon 스타일)
|
|
||||||
cartAction?: CardCartActionConfig;
|
cartAction?: CardCartActionConfig;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ const nextConfig = {
|
||||||
// 로컬 개발: http://127.0.0.1:8080 사용
|
// 로컬 개발: http://127.0.0.1:8080 사용
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
||||||
const backendUrl = process.env.SERVER_API_URL || "http://pms-backend-mac:8080";
|
const backendUrl = process.env.SERVER_API_URL || "http://localhost:8080";
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/api/:path*",
|
source: "/api/:path*",
|
||||||
|
|
@ -50,7 +50,7 @@ const nextConfig = {
|
||||||
// 환경 변수 (런타임에 읽기)
|
// 환경 변수 (런타임에 읽기)
|
||||||
env: {
|
env: {
|
||||||
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
||||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://pms-backend-mac:8080/api",
|
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue