Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
commit
dd86d5e63c
|
|
@ -3,6 +3,10 @@
|
|||
"agent-orchestrator": {
|
||||
"command": "node",
|
||||
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
|
||||
},
|
||||
"Framelink Figma MCP": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
# Claude Code (로컬 전용 - Git 제외)
|
||||
.claude/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
|
@ -292,3 +295,6 @@ claude.md
|
|||
*-test-screenshots/
|
||||
*-screenshots/
|
||||
*-test.mjs
|
||||
|
||||
# 개인 작업 문서 (popdocs)
|
||||
popdocs/
|
||||
653
PLAN.MD
653
PLAN.MD
|
|
@ -1,139 +1,548 @@
|
|||
# 프로젝트: V2/V2 컴포넌트 설정 스키마 정비
|
||||
# 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성
|
||||
|
||||
## 개요
|
||||
|
||||
레거시 컴포넌트를 제거하고, V2/V2 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 한 곳에서 관리한다.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
1. [x] 레거시 컴포넌트 스키마 제거
|
||||
2. [x] V2 컴포넌트 overrides 스키마 정의 (16개)
|
||||
3. [x] V2 컴포넌트 overrides 스키마 정의 (9개)
|
||||
4. [x] componentConfig.ts 한 파일에서 통합 관리
|
||||
|
||||
## 정의된 V2 컴포넌트 (18개)
|
||||
|
||||
- v2-table-list, v2-button-primary, v2-text-display
|
||||
- v2-split-panel-layout, v2-section-card, v2-section-paper
|
||||
- v2-divider-line, v2-repeat-container, v2-rack-structure
|
||||
- v2-numbering-rule, v2-category-manager, v2-pivot-grid
|
||||
- v2-location-swap-selector, v2-aggregation-widget
|
||||
- v2-card-display, v2-table-search-widget, v2-tabs-widget
|
||||
- v2-v2-repeater
|
||||
|
||||
## 정의된 V2 컴포넌트 (9개)
|
||||
|
||||
- v2-input, v2-select, v2-date
|
||||
- v2-list, v2-layout, v2-group
|
||||
- v2-media, v2-biz, v2-hierarchy
|
||||
|
||||
## 테스트 계획
|
||||
|
||||
### 1단계: 기본 기능
|
||||
|
||||
- [x] V2 레이아웃 저장 시 컴포넌트별 overrides 스키마 검증 통과
|
||||
- [x] V2 컴포넌트 기본값과 스키마가 매칭됨
|
||||
|
||||
### 2단계: 에러 케이스
|
||||
|
||||
- [x] 잘못된 overrides 입력 시 Zod 검증 실패 처리 (safeParse + console.warn + graceful fallback)
|
||||
- [x] 누락된 기본값 컴포넌트 저장 시 안전한 기본값 적용 (레지스트리 조회 → 빈 객체)
|
||||
|
||||
## 에러 처리 계획
|
||||
|
||||
- 스키마 파싱 실패 시 로그/에러 메시지 표준화
|
||||
- 기본값 누락 시 안전한 fallback 적용
|
||||
|
||||
## 진행 상태
|
||||
|
||||
- [x] 레거시 컴포넌트 제거 완료
|
||||
- [x] V2/V2 스키마 정의 완료
|
||||
- [x] 한 파일 통합 관리 완료
|
||||
|
||||
# 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후)
|
||||
|
||||
## 개요
|
||||
|
||||
채번/카테고리에서 `menu_objid` 의존성 제거 완료 후, 화면 복제 기능을 새 DB 구조에 맞게 수정하고 테스트합니다.
|
||||
|
||||
## 핵심 변경사항
|
||||
|
||||
### DB 구조 변경 (완료)
|
||||
|
||||
- 채번규칙: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반
|
||||
- 카테고리: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반
|
||||
- 복제 순서 의존성 문제 해결
|
||||
|
||||
### 복제 옵션 정리 (완료)
|
||||
|
||||
- [x] **삭제**: 코드 카테고리 + 코드 복사 옵션
|
||||
- [x] **삭제**: 연쇄관계 설정 복사 옵션
|
||||
- [x] **이름 변경**: "카테고리 매핑 + 값 복사" → "카테고리 값 복사"
|
||||
|
||||
### 현재 복제 옵션 (3개)
|
||||
|
||||
1. **채번 규칙 복사** - 채번규칙 복제
|
||||
2. **카테고리 값 복사** - 카테고리 값 복제 (table_column_category_values)
|
||||
3. **테이블 타입관리 입력타입 설정 복사** - table_type_columns 복제
|
||||
> **작성일**: 2026-02-10
|
||||
> **상태**: 코딩 완료 (방어 로직 패치 포함)
|
||||
> **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성
|
||||
|
||||
---
|
||||
|
||||
## 테스트 계획
|
||||
## 1. 문제 요약
|
||||
|
||||
### 1. 화면 간 연결 복제 테스트
|
||||
pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가.
|
||||
|
||||
- [ ] 수주관리 1번→2번→3번→4번 화면 연결 상태에서 복제
|
||||
- [ ] 복제 후 연결 관계가 유지되는지 확인
|
||||
- [ ] 각 화면의 고유 키값이 새로운 화면을 참조하도록 변경되는지 확인
|
||||
|
||||
### 2. 제어관리 복제 테스트
|
||||
|
||||
- [ ] 다른 회사로 제어관리 복제
|
||||
- [ ] 복제된 플로우 스텝/연결이 정상 작동하는지 확인
|
||||
|
||||
### 3. 추가 옵션 복제 테스트
|
||||
|
||||
- [ ] 채번규칙 복사 정상 작동 확인
|
||||
- [ ] 카테고리 값 복사 정상 작동 확인
|
||||
- [ ] 테이블 타입관리 입력타입 설정 복사 정상 작동 확인
|
||||
|
||||
### 4. 기본 복제 테스트
|
||||
|
||||
- [ ] 단일 화면 복제 (모달 포함)
|
||||
- [ ] 그룹 전체 복제 (재귀적)
|
||||
- [ ] 메뉴 동기화 정상 작동
|
||||
| # | 문제 | 심각도 | 영향 |
|
||||
|---|------|--------|------|
|
||||
| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 |
|
||||
| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 |
|
||||
| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 |
|
||||
| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 |
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
## 2. 수정 대상 파일 (2개)
|
||||
|
||||
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
|
||||
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
|
||||
- `backend-node/src/services/screenManagementService.ts` - 복제 서비스
|
||||
- `backend-node/src/services/numberingRuleService.ts` - 채번규칙 서비스
|
||||
- `docs/DB_STRUCTURE_DIAGRAM.md` - DB 구조 문서
|
||||
### 파일 A: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx`
|
||||
|
||||
## 진행 상태
|
||||
**변경 유형**: 설정 UI 추가 3건
|
||||
|
||||
#### 변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래)
|
||||
|
||||
집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가.
|
||||
|
||||
**추가할 위치**: `{/* 집계 함수 + 대상 컬럼 */}` 블록 다음, `{/* 자동 새로고침 */}` 블록 이전
|
||||
|
||||
**추가할 코드** (약 50줄):
|
||||
|
||||
```tsx
|
||||
{/* 그룹핑 (차트용 X축 분류) */}
|
||||
{dataSource.aggregation && (
|
||||
<div>
|
||||
<Label className="text-xs">그룹핑 (X축)</Label>
|
||||
<Popover open={groupByOpen} onOpenChange={setGroupByOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={groupByOpen}
|
||||
disabled={loadingCols}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{dataSource.aggregation.groupBy?.length
|
||||
? dataSource.aggregation.groupBy.join(", ")
|
||||
: "없음 (단일 값)"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
컬럼을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{columns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
const current = dataSource.aggregation?.groupBy ?? [];
|
||||
const isSelected = current.includes(col.name);
|
||||
const newGroupBy = isSelected
|
||||
? current.filter((g) => g !== col.name)
|
||||
: [...current, col.name];
|
||||
onChange({
|
||||
...dataSource,
|
||||
aggregation: {
|
||||
...dataSource.aggregation!,
|
||||
groupBy: newGroupBy.length > 0 ? newGroupBy : undefined,
|
||||
},
|
||||
});
|
||||
setGroupByOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
dataSource.aggregation?.groupBy?.includes(col.name)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{col.name}</span>
|
||||
<span className="ml-1 text-muted-foreground">({col.type})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
차트에서 X축 카테고리로 사용됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**필요한 state 추가** (DataSourceEditor 내부, 기존 state 옆):
|
||||
|
||||
```tsx
|
||||
const [groupByOpen, setGroupByOpen] = useState(false);
|
||||
```
|
||||
|
||||
#### 변경 A-2: 차트 타입 설정에 xAxisColumn/yAxisColumn 추가 (라인 1187~1212 부근)
|
||||
|
||||
**추가할 위치**: `{item.subType === "chart" && (` 블록 내부, 차트 유형 Select 다음
|
||||
|
||||
**추가할 코드** (약 30줄):
|
||||
|
||||
```tsx
|
||||
{/* X축 컬럼 */}
|
||||
<div>
|
||||
<Label className="text-xs">X축 컬럼</Label>
|
||||
<Input
|
||||
value={item.chartConfig?.xAxisColumn ?? ""}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
...item,
|
||||
chartConfig: {
|
||||
...item.chartConfig,
|
||||
chartType: item.chartConfig?.chartType ?? "bar",
|
||||
xAxisColumn: e.target.value || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="groupBy 컬럼명 (비우면 자동)"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 변경 A-3: 통계 카드 타입에 카테고리 설정 UI 추가 (라인 1272 부근, 게이지 설정 다음)
|
||||
|
||||
**추가할 위치**: `{item.subType === "gauge" && (` 블록 다음에 새 블록 추가
|
||||
|
||||
**추가할 코드** (약 100줄): `StatCategoryEditor` 인라인 블록
|
||||
|
||||
```tsx
|
||||
{item.subType === "stat-card" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">카테고리 설정</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => {
|
||||
const currentCats = item.statConfig?.categories ?? [];
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: {
|
||||
...item.statConfig,
|
||||
categories: [
|
||||
...currentCats,
|
||||
{
|
||||
label: `카테고리 ${currentCats.length + 1}`,
|
||||
filter: { column: "", operator: "=", value: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
카테고리 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(item.statConfig?.categories ?? []).map((cat, catIdx) => (
|
||||
<div key={catIdx} className="space-y-1 rounded border p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
value={cat.label}
|
||||
onChange={(e) => {
|
||||
const newCats = [...(item.statConfig?.categories ?? [])];
|
||||
newCats[catIdx] = { ...cat, label: e.target.value };
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: { ...item.statConfig, categories: newCats },
|
||||
});
|
||||
}}
|
||||
placeholder="라벨 (예: 수주)"
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={cat.color ?? ""}
|
||||
onChange={(e) => {
|
||||
const newCats = [...(item.statConfig?.categories ?? [])];
|
||||
newCats[catIdx] = { ...cat, color: e.target.value || undefined };
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: { ...item.statConfig, categories: newCats },
|
||||
});
|
||||
}}
|
||||
placeholder="#색상코드"
|
||||
className="h-6 w-20 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive"
|
||||
onClick={() => {
|
||||
const newCats = (item.statConfig?.categories ?? []).filter(
|
||||
(_, i) => i !== catIdx
|
||||
);
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: { ...item.statConfig, categories: newCats },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* 필터 조건: 컬럼 / 연산자 / 값 */}
|
||||
<div className="flex items-center gap-1 text-[10px]">
|
||||
<Input
|
||||
value={cat.filter.column}
|
||||
onChange={(e) => {
|
||||
const newCats = [...(item.statConfig?.categories ?? [])];
|
||||
newCats[catIdx] = {
|
||||
...cat,
|
||||
filter: { ...cat.filter, column: e.target.value },
|
||||
};
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: { ...item.statConfig, categories: newCats },
|
||||
});
|
||||
}}
|
||||
placeholder="컬럼"
|
||||
className="h-6 w-20 text-[10px]"
|
||||
/>
|
||||
<Select
|
||||
value={cat.filter.operator}
|
||||
onValueChange={(val) => {
|
||||
const newCats = [...(item.statConfig?.categories ?? [])];
|
||||
newCats[catIdx] = {
|
||||
...cat,
|
||||
filter: { ...cat.filter, operator: val as FilterOperator },
|
||||
};
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: { ...item.statConfig, categories: newCats },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-16 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="=" className="text-xs">= 같음</SelectItem>
|
||||
<SelectItem value="!=" className="text-xs">!= 다름</SelectItem>
|
||||
<SelectItem value="like" className="text-xs">LIKE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={String(cat.filter.value ?? "")}
|
||||
onChange={(e) => {
|
||||
const newCats = [...(item.statConfig?.categories ?? [])];
|
||||
newCats[catIdx] = {
|
||||
...cat,
|
||||
filter: { ...cat.filter, value: e.target.value },
|
||||
};
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: { ...item.statConfig, categories: newCats },
|
||||
});
|
||||
}}
|
||||
placeholder="값"
|
||||
className="h-6 flex-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(item.statConfig?.categories ?? []).length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 파일 B: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx`
|
||||
|
||||
**변경 유형**: 데이터 처리 로직 수정 2건
|
||||
|
||||
#### 변경 B-1: 차트 xAxisColumn 자동 설정 (라인 276~283 부근)
|
||||
|
||||
차트에 groupBy가 있지만 xAxisColumn이 설정되지 않은 경우, 첫 번째 groupBy 컬럼을 자동으로 xAxisColumn에 반영.
|
||||
|
||||
**현재 코드** (라인 276~283):
|
||||
```tsx
|
||||
case "chart":
|
||||
return (
|
||||
<ChartItemComponent
|
||||
item={item}
|
||||
rows={itemData.rows}
|
||||
containerWidth={containerWidth}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
**변경 코드**:
|
||||
```tsx
|
||||
case "chart": {
|
||||
// groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정
|
||||
const chartItem = { ...item };
|
||||
if (
|
||||
item.dataSource.aggregation?.groupBy?.length &&
|
||||
!item.chartConfig?.xAxisColumn
|
||||
) {
|
||||
chartItem.chartConfig = {
|
||||
...chartItem.chartConfig,
|
||||
chartType: chartItem.chartConfig?.chartType ?? "bar",
|
||||
xAxisColumn: item.dataSource.aggregation.groupBy[0],
|
||||
};
|
||||
}
|
||||
return (
|
||||
<ChartItemComponent
|
||||
item={chartItem}
|
||||
rows={itemData.rows}
|
||||
containerWidth={containerWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297)
|
||||
|
||||
**현재 코드** (버그):
|
||||
```tsx
|
||||
case "stat-card": {
|
||||
const categoryData: Record<string, number> = {};
|
||||
if (item.statConfig?.categories) {
|
||||
for (const cat of item.statConfig.categories) {
|
||||
categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값
|
||||
}
|
||||
}
|
||||
return (
|
||||
<StatCardComponent item={item} categoryData={categoryData} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**변경 코드**:
|
||||
```tsx
|
||||
case "stat-card": {
|
||||
const categoryData: Record<string, number> = {};
|
||||
if (item.statConfig?.categories) {
|
||||
for (const cat of item.statConfig.categories) {
|
||||
if (cat.filter.column && cat.filter.value) {
|
||||
// 카테고리 필터로 rows 필터링
|
||||
const filtered = itemData.rows.filter((row) => {
|
||||
const cellValue = String(row[cat.filter.column] ?? "");
|
||||
const filterValue = String(cat.filter.value ?? "");
|
||||
switch (cat.filter.operator) {
|
||||
case "=":
|
||||
return cellValue === filterValue;
|
||||
case "!=":
|
||||
return cellValue !== filterValue;
|
||||
case "like":
|
||||
return cellValue.toLowerCase().includes(filterValue.toLowerCase());
|
||||
default:
|
||||
return cellValue === filterValue;
|
||||
}
|
||||
});
|
||||
categoryData[cat.label] = filtered.length;
|
||||
} else {
|
||||
categoryData[cat.label] = itemData.rows.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<StatCardComponent item={item} categoryData={categoryData} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**주의**: 이 방식은 rows에 전체 데이터가 있어야 작동함. 통계 카드의 dataSource에 집계 함수를 설정하지 않고(또는 groupBy를 사용하여) 원시 rows를 가져와야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 구현 순서 (의존성 기반)
|
||||
|
||||
| 순서 | 작업 | 파일 | 의존성 | 상태 |
|
||||
|------|------|------|--------|------|
|
||||
| 1 | A-1: DataSourceEditor에 groupBy UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
||||
| 2 | A-2: 차트 xAxisColumn 입력 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
||||
| 3 | A-3: 통계 카드 카테고리 설정 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
||||
| 4 | B-1: 차트 xAxisColumn 자동 보정 로직 | PopDashboardComponent.tsx | 순서 1 | [x] |
|
||||
| 5 | B-2: 통계 카드 카테고리별 필터 적용 | PopDashboardComponent.tsx | 순서 3 | [x] |
|
||||
| 6 | 린트 검사 | 전체 | 순서 1~5 | [x] |
|
||||
| 7 | C-1: SQL 빌더 방어 로직 (빈 컬럼/테이블 차단) | dataFetcher.ts | 없음 | [x] |
|
||||
| 8 | C-2: refreshInterval 최소값 강제 (5초) | PopDashboardComponent.tsx | 없음 | [x] |
|
||||
| 9 | 브라우저 테스트 | - | 순서 1~8 | [ ] |
|
||||
|
||||
순서 1, 2, 3은 서로 독립이므로 병렬 가능.
|
||||
순서 4는 순서 1의 groupBy 값이 있어야 의미 있음.
|
||||
순서 5는 순서 3의 카테고리 설정이 있어야 의미 있음.
|
||||
순서 7, 8은 백엔드 부하 방지를 위한 방어 패치.
|
||||
|
||||
---
|
||||
|
||||
## 4. 사전 충돌 검사 결과
|
||||
|
||||
### 새로 추가할 식별자 목록
|
||||
|
||||
| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 |
|
||||
|--------|------|-----------|-----------|-----------|
|
||||
| `groupByOpen` | state (boolean) | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 |
|
||||
| `setGroupByOpen` | state setter | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 |
|
||||
| `chartItem` | const (DashboardItem) | PopDashboardComponent.tsx renderSingleItem 내부 | 동일 함수 내부 | 충돌 없음 |
|
||||
|
||||
**Grep 검색 결과** (전체 pop-dashboard 폴더):
|
||||
- `groupByOpen`: 0건 - 충돌 없음
|
||||
- `setGroupByOpen`: 0건 - 충돌 없음
|
||||
- `groupByColumns`: 0건 - 충돌 없음
|
||||
- `chartItem`: 0건 - 충돌 없음
|
||||
- `StatCategoryEditor`: 0건 - 충돌 없음
|
||||
- `loadCategoryData`: 0건 - 충돌 없음
|
||||
|
||||
### 기존 타입/함수 재사용 목록
|
||||
|
||||
| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 |
|
||||
|------------|-----------|------------------------|
|
||||
| `DataSourceConfig.aggregation.groupBy` | types.ts 라인 155 | A-1 UI에서 읽기/쓰기 |
|
||||
| `ChartItemConfig.xAxisColumn` | types.ts 라인 248 | A-2 UI, B-1 자동 보정 |
|
||||
| `StatCategory` | types.ts 라인 261 | A-3 카테고리 편집 |
|
||||
| `StatCardConfig.categories` | types.ts 라인 268 | A-3 UI에서 읽기/쓰기 |
|
||||
| `FilterOperator` | types.ts (import 이미 존재) | A-3 카테고리 필터 Select |
|
||||
| `columns` (state) | PopDashboardConfig.tsx DataSourceEditor 내부 | A-1 groupBy 컬럼 목록 |
|
||||
|
||||
**사용처 있는데 정의 누락된 항목: 없음**
|
||||
|
||||
---
|
||||
|
||||
## 5. 에러 함정 경고
|
||||
|
||||
### 함정 1: 차트에 groupBy만 설정하고 xAxisColumn을 비우면
|
||||
ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `{status: "수주", value: 79}` 형태.
|
||||
`name` 키가 없으므로 X축이 빈 채로 렌더링됨.
|
||||
**B-1의 자동 보정 로직이 필수**. 순서 4를 빠뜨리면 차트가 깨짐.
|
||||
|
||||
### 함정 2: 통계 카드에 집계 함수를 설정하면
|
||||
집계(COUNT 등)가 설정되면 rows에 `[{value: 87}]` 하나만 들어옴.
|
||||
카테고리별 필터링이 작동하려면 **집계 함수를 "없음"으로 두거나, groupBy를 설정**해야 개별 행이 rows에 포함됨.
|
||||
통계 카드에서는 **집계를 사용하지 않는 것이 올바른 사용법**.
|
||||
설정 가이드 문서에 이 점을 명시해야 함.
|
||||
|
||||
### 함정 3: PopDashboardConfig.tsx의 import 누락
|
||||
현재 `FilterOperator`는 이미 import되어 있음 (라인 54).
|
||||
`StatCategory`는 직접 사용하지 않고 `item.statConfig.categories` 구조로 접근하므로 import 불필요.
|
||||
**새로운 import 추가 필요 없음.**
|
||||
|
||||
### 함정 4: 통계 카드 카테고리 필터에서 숫자 비교
|
||||
`String(row[col])` vs `String(filter.value)` 비교이므로, 숫자 컬럼도 문자열로 비교됨.
|
||||
`"100" === "100"`은 정상 동작하지만, `"100.00" !== "100"`이 될 수 있음.
|
||||
현재 대부분 컬럼이 varchar이므로 문제없지만, numeric 컬럼 사용 시 주의.
|
||||
|
||||
### 함정 5: DataSourceEditor의 columns state 타이밍
|
||||
`groupByOpen` Popover에서 `columns` 배열을 사용하는데, 테이블 선택 직후 columns가 아직 로딩 중일 수 있음.
|
||||
기존 코드에서 `loadingCols` 상태로 버튼을 disabled 처리하고 있으므로 문제없음.
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 방법
|
||||
|
||||
### 차트 (BUG-1, BUG-2)
|
||||
1. 아이템 추가 > "차트" 선택
|
||||
2. 테이블: `sales_order_mng`, 집계: COUNT, 컬럼: `id`, 그룹핑: `status`
|
||||
3. 차트 유형: 막대 차트
|
||||
4. 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1
|
||||
|
||||
### 통계 카드 (BUG-3, BUG-4)
|
||||
1. 아이템 추가 > "통계 카드" 선택
|
||||
2. 테이블: `sales_order_mng`, **집계: 없음** (중요!)
|
||||
3. 카테고리 추가:
|
||||
- "수주" / status / = / 수주
|
||||
- "진행중" / status / = / 진행중
|
||||
- "완료" / status / = / 완료
|
||||
4. 기대 결과: 수주 79, 진행중 7, 완료 1
|
||||
|
||||
---
|
||||
|
||||
## 이전 완료 계획 (아카이브)
|
||||
|
||||
<details>
|
||||
<summary>POP 뷰어 스크롤 수정 (완료)</summary>
|
||||
|
||||
- [x] 라인 185: overflow-hidden 제거
|
||||
- [x] 라인 266: overflow-auto 공통 적용
|
||||
- [x] 라인 275: 일반 모드 min-h-full 추가
|
||||
- [x] 린트 검사 통과
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>POP 뷰어 실제 컴포넌트 렌더링 (완료)</summary>
|
||||
|
||||
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가
|
||||
- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체
|
||||
- [x] 린트 검사 통과
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>V2/V2 컴포넌트 설정 스키마 정비 (완료)</summary>
|
||||
|
||||
- [x] 레거시 컴포넌트 스키마 제거
|
||||
- [x] V2 컴포넌트 overrides 스키마 정의 (16개)
|
||||
- [x] V2 컴포넌트 overrides 스키마 정의 (9개)
|
||||
- [x] componentConfig.ts 한 파일에서 통합 관리
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>화면 복제 기능 개선 (진행 중)</summary>
|
||||
|
||||
- [완료] DB 구조 개편 (menu_objid 의존성 제거)
|
||||
- [완료] 복제 옵션 정리 (코드카테고리/연쇄관계 삭제, 이름 변경)
|
||||
- [완료] 화면 간 연결 복제 버그 수정 (targetScreenId 매핑 추가)
|
||||
- [완료] 복제 옵션 정리
|
||||
- [완료] 화면 간 연결 복제 버그 수정
|
||||
- [대기] 화면 간 연결 복제 테스트
|
||||
- [대기] 제어관리 복제 테스트
|
||||
- [대기] 추가 옵션 복제 테스트
|
||||
|
||||
---
|
||||
|
||||
## 수정 이력
|
||||
|
||||
### 2026-01-26: 버튼 targetScreenId 매핑 버그 수정
|
||||
|
||||
**문제**: 그룹 복제 시 버튼의 `targetScreenId`가 새 화면으로 매핑되지 않음
|
||||
|
||||
- 수주관리 1→2→3→4 화면 복제 시 연결이 깨지는 문제
|
||||
|
||||
**수정 파일**: `backend-node/src/services/screenManagementService.ts`
|
||||
|
||||
- `updateTabScreenReferences` 함수에 `targetScreenId` 처리 로직 추가
|
||||
- 쿼리에 `targetScreenId` 검색 조건 추가
|
||||
- 문자열/숫자 타입 모두 처리
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,696 @@
|
|||
# POP 컴포넌트 정의서 v8.0
|
||||
|
||||
## POP 헌법 (공통 규칙)
|
||||
|
||||
### 제1조. 컴포넌트의 정의
|
||||
|
||||
- 컴포넌트란 디자이너가 그리드에 배치하는 것이다
|
||||
- 그리드에 배치하지 않는 것은 컴포넌트가 아니다
|
||||
|
||||
### 제2조. 컴포넌트의 독립성
|
||||
|
||||
- 모든 컴포넌트는 독립적으로 동작한다
|
||||
- 컴포넌트는 다른 컴포넌트의 존재를 직접 알지 못한다 (이벤트 버스로만 통신)
|
||||
|
||||
### 제3조. 데이터의 자유
|
||||
|
||||
- 모든 컴포넌트는 자신의 테이블 + 외부 테이블을 자유롭게 조인할 수 있다
|
||||
- 컬럼별로 read/write/readwrite/hidden을 개별 설정할 수 있다
|
||||
- 보유 데이터 중 원하는 컬럼만 골라서 저장할 수 있다
|
||||
|
||||
### 제4조. 통신의 규칙
|
||||
|
||||
- 컴포넌트 간 통신은 반드시 이벤트 버스(usePopEvent)를 통한다
|
||||
- 컴포넌트가 다른 컴포넌트를 직접 참조하거나 호출하지 않는다
|
||||
- 이벤트는 화면 단위로 격리된다 (다른 POP 화면의 이벤트를 받지 않는다)
|
||||
- 같은 화면 안에서는 이벤트를 통해 자유롭게 데이터를 주고받을 수 있다
|
||||
|
||||
### 제5조. 역할의 분리
|
||||
|
||||
- 조회용 입력(pop-search)과 저장용 입력(pop-field)은 다른 컴포넌트다
|
||||
- 이동/실행(pop-icon)과 값 선택 후 반환(pop-lookup)은 다른 컴포넌트다
|
||||
- 자주 쓰는 패턴은 하나로 합치되, 흐름 자체는 강제하고 보이는 방식만 옵션으로 제공한다
|
||||
|
||||
### 제6조. 시스템 설정도 컴포넌트다
|
||||
|
||||
- 프로필, 테마, 대시보드 보이기/숨기기 같은 시스템 설정도 컴포넌트(pop-system)로 만든다
|
||||
- 디자이너가 pop-system을 배치하지 않으면 해당 화면에 설정 기능이 없다
|
||||
- 이를 통해 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정한다
|
||||
|
||||
### 제7조. 디자이너의 권한
|
||||
|
||||
- 디자이너는 컴포넌트를 배치하고 설정한다
|
||||
- 디자이너는 사용자에게 커스텀을 허용할지 말지 결정한다 (userConfigurable)
|
||||
- 디자이너가 "사용자 커스텀 허용 = OFF"로 설정하면, 사용자는 변경할 수 없다
|
||||
- 컴포넌트의 옵션 설정(어떻게 저장하고 어떻게 조회하는지 등)은 디자이너가 결정한다
|
||||
|
||||
### 제8조. 컴포넌트의 구성
|
||||
|
||||
- 모든 컴포넌트는 3개 파일로 구성된다: 실제 컴포넌트, 디자인 미리보기, 설정 패널
|
||||
- 모든 컴포넌트는 레지스트리에 등록해야 디자이너에 나타난다
|
||||
- 모든 컴포넌트 인스턴스는 userConfigurable, displayName 공통 속성을 가진다
|
||||
|
||||
### 제9조. 모달 화면의 설계
|
||||
|
||||
- 모달은 인라인(컴포넌트 설정만으로 구성)과 외부 참조(별도 POP 화면 연결) 두 가지 방식이 있다
|
||||
- 단순한 목록 선택은 인라인 모달을 사용한다 (설정만으로 완결)
|
||||
- 복잡한 검색/필터가 필요하거나 여러 곳에서 재사용하는 모달은 별도 POP 화면을 만들어 참조한다
|
||||
- 모달 안의 화면도 동일한 POP 컴포넌트 시스템으로 구성된다 (같은 그리드, 같은 컴포넌트)
|
||||
- 모달 화면의 layout_data는 기존 screen_layouts_pop 테이블에 저장한다 (DB 변경 불필요)
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태
|
||||
|
||||
- 그리드 시스템 (v5.2): 완성
|
||||
- 컴포넌트 레지스트리: 완성 (PopComponentRegistry.ts)
|
||||
- 구현 완료: `pop-text` 1개 (pop-text.tsx)
|
||||
- 기존 `components-spec.md`는 v4 기준이라 갱신 필요
|
||||
|
||||
## 아키텍처 개요
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph designer [디자이너]
|
||||
Palette[컴포넌트 팔레트]
|
||||
Grid[CSS Grid 캔버스]
|
||||
ConfigPanel[속성 설정 패널]
|
||||
end
|
||||
|
||||
subgraph registry [레지스트리]
|
||||
Registry[PopComponentRegistry]
|
||||
end
|
||||
|
||||
subgraph infra [공통 인프라]
|
||||
DataSource[useDataSource 훅]
|
||||
EventBus[usePopEvent 훅]
|
||||
ActionRunner[usePopAction 훅]
|
||||
end
|
||||
|
||||
subgraph components [9개 컴포넌트]
|
||||
Text[pop-text - 완성]
|
||||
Dashboard[pop-dashboard]
|
||||
Table[pop-table]
|
||||
Button[pop-button]
|
||||
Icon[pop-icon]
|
||||
Search[pop-search]
|
||||
Field[pop-field]
|
||||
Lookup[pop-lookup]
|
||||
System[pop-system]
|
||||
end
|
||||
|
||||
subgraph backend [기존 백엔드 API]
|
||||
DataAPI[dataApi - 동적 CRUD]
|
||||
DashAPI[dashboardApi - 통계 쿼리]
|
||||
CodeAPI[commonCodeApi - 공통코드]
|
||||
NumberAPI[numberingRuleApi - 채번]
|
||||
end
|
||||
|
||||
Palette --> Grid
|
||||
Grid --> ConfigPanel
|
||||
ConfigPanel --> Registry
|
||||
|
||||
Registry --> components
|
||||
components --> infra
|
||||
infra --> backend
|
||||
EventBus -.->|컴포넌트 간 통신| components
|
||||
System -.->|보이기/숨기기 제어| components
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 공통 인프라 (모든 컴포넌트가 공유)
|
||||
|
||||
### 핵심 원칙: 모든 컴포넌트는 데이터를 자유롭게 다룬다
|
||||
|
||||
1. **데이터 전달**: 모든 컴포넌트는 자신이 보유한 데이터를 다른 컴포넌트에 전달/수신 가능
|
||||
2. **테이블 조인**: 자신의 테이블 + 외부 테이블 자유롭게 조인하여 데이터 구성
|
||||
3. **컬럼별 CRUD 제어**: 컬럼 단위로 "조회만" / "저장 대상" / "숨김"을 개별 설정 가능
|
||||
4. **선택적 저장**: 보유 데이터 중 원하는 컬럼만 골라서 저장/수정/삭제 가능
|
||||
|
||||
### 공통 인스턴스 속성 (모든 컴포넌트 배치 시 설정 가능)
|
||||
|
||||
디자이너가 컴포넌트를 그리드에 배치할 때 설정하는 공통 속성:
|
||||
|
||||
- `userConfigurable`: boolean - 사용자가 이 컴포넌트를 숨길 수 있는지 (개인 설정 패널에 노출)
|
||||
- `displayName`: string - 개인 설정 패널에 보여줄 이름 (예: "금일 생산실적")
|
||||
|
||||
### 1. DataSourceConfig (데이터 소스 설정 타입)
|
||||
|
||||
모든 데이터 연동 컴포넌트가 사용하는 표준 설정 구조:
|
||||
|
||||
- `tableName`: 대상 테이블
|
||||
- `columns`: 컬럼 바인딩 목록 (ColumnBinding 배열)
|
||||
- `filters`: 필터 조건 배열
|
||||
- `sort`: 정렬 설정
|
||||
- `aggregation`: 집계 함수 (count, sum, avg, min, max)
|
||||
- `joins`: 테이블 조인 설정 (JoinConfig 배열)
|
||||
- `refreshInterval`: 자동 새로고침 주기 (초)
|
||||
- `limit`: 조회 건수 제한
|
||||
|
||||
### 1-1. ColumnBinding (컬럼별 읽기/쓰기 제어)
|
||||
|
||||
각 컬럼이 컴포넌트에서 어떤 역할을 하는지 개별 설정:
|
||||
|
||||
- `columnName`: 컬럼명
|
||||
- `sourceTable`: 소속 테이블 (조인된 외부 테이블 포함)
|
||||
- `mode`: "read" | "write" | "readwrite" | "hidden"
|
||||
- read: 조회만 (화면에 표시하되 저장 안 함)
|
||||
- write: 저장 대상 (사용자 입력 -> DB 저장)
|
||||
- readwrite: 조회 + 저장 모두
|
||||
- hidden: 내부 참조용 (화면에 안 보이지만 다른 컴포넌트에 전달 가능)
|
||||
- `label`: 화면 표시 라벨
|
||||
- `defaultValue`: 기본값
|
||||
|
||||
예시: 발주 품목 카드에서 5개 컬럼 중 3개만 저장
|
||||
|
||||
```
|
||||
columns: [
|
||||
{ columnName: "item_code", sourceTable: "order_items", mode: "read" },
|
||||
{ columnName: "item_name", sourceTable: "item_info", mode: "read" },
|
||||
{ columnName: "inbound_qty", sourceTable: "order_items", mode: "readwrite" },
|
||||
{ columnName: "warehouse", sourceTable: "order_items", mode: "write" },
|
||||
{ columnName: "memo", sourceTable: "order_items", mode: "write" },
|
||||
]
|
||||
```
|
||||
|
||||
### 1-2. JoinConfig (테이블 조인 설정)
|
||||
|
||||
외부 테이블과 자유롭게 조인:
|
||||
|
||||
- `targetTable`: 조인할 외부 테이블명
|
||||
- `joinType`: "inner" | "left" | "right"
|
||||
- `on`: 조인 조건 { sourceColumn, targetColumn }
|
||||
- `columns`: 가져올 컬럼 목록
|
||||
|
||||
### 2. useDataSource 훅
|
||||
|
||||
DataSourceConfig를 받아서 기존 API를 호출하고 결과를 반환:
|
||||
|
||||
- 로딩/에러/데이터 상태 관리
|
||||
- 자동 새로고침 타이머
|
||||
- 필터 변경 시 자동 재조회
|
||||
- 기존 `dataApi`, `dashboardApi` 활용
|
||||
- **CRUD 함수 제공**: save(data), update(id, data), delete(id)
|
||||
- ColumnBinding의 mode가 "write" 또는 "readwrite"인 컬럼만 저장 대상에 포함
|
||||
- "read" 컬럼은 저장 시 자동 제외
|
||||
|
||||
### 3. usePopEvent 훅 (이벤트 버스 - 데이터 전달 포함)
|
||||
|
||||
컴포넌트 간 통신 (단순 이벤트 + 데이터 페이로드):
|
||||
|
||||
- `publish(eventName, payload)`: 이벤트 발행
|
||||
- `subscribe(eventName, callback)`: 이벤트 구독
|
||||
- `getSharedData(key)`: 공유 데이터 직접 읽기
|
||||
- `setSharedData(key, value)`: 공유 데이터 직접 쓰기
|
||||
- 화면 단위 스코프 (다른 POP 화면과 격리)
|
||||
|
||||
### 4. PopActionConfig (액션 설정 타입)
|
||||
|
||||
모든 컴포넌트가 사용할 수 있는 액션 표준 구조:
|
||||
|
||||
- `type`: "navigate" | "modal" | "save" | "delete" | "api" | "event" | "refresh"
|
||||
- `navigate`: { screenId, url }
|
||||
- `modal`: { mode, title, screenId, inlineConfig, modalSize }
|
||||
- mode: "inline" (설정만으로 구성) | "screen-ref" (별도 화면 참조)
|
||||
- title: 모달 제목
|
||||
- screenId: mode가 "screen-ref"일 때 참조할 POP 화면 ID
|
||||
- inlineConfig: mode가 "inline"일 때 사용할 DataSourceConfig + 표시 설정
|
||||
- modalSize: { width, height } 모달 크기
|
||||
- `save`: { targetColumns }
|
||||
- `delete`: { confirmMessage }
|
||||
- `api`: { method, endpoint, body }
|
||||
- `event`: { eventName, payload }
|
||||
- `refresh`: { targetComponents }
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 정의 (9개)
|
||||
|
||||
### 1. pop-text (완성)
|
||||
|
||||
- **한 줄 정의**: 보여주기만 함
|
||||
- **카테고리**: display
|
||||
- **역할**: 정적 표시 전용 (이벤트 없음)
|
||||
- **서브타입**: text, datetime, image, title
|
||||
- **데이터**: 없음 (정적 콘텐츠)
|
||||
- **이벤트**: 발행 없음, 수신 없음
|
||||
- **설정**: 내용, 폰트 크기/굵기, 좌우/상하 정렬, 이미지 URL/맞춤/크기, 날짜 포맷 빌더
|
||||
|
||||
### 2. pop-dashboard (신규 - 2026-02-09 토의 결과 반영)
|
||||
|
||||
- **한 줄 정의**: 여러 집계 아이템을 묶어서 다양한 방식으로 보여줌
|
||||
- **카테고리**: display
|
||||
- **역할**: 숫자 데이터를 집계/계산하여 시각화. 하나의 컴포넌트 안에 여러 집계 아이템을 담는 컨테이너
|
||||
- **구조**: 1개 pop-dashboard = 여러 DashboardItem의 묶음. 각 아이템은 독립적으로 데이터 소스/서브타입/보이기숨기기 설정 가능
|
||||
- **서브타입** (아이템별로 선택, 한 묶음에 혼합 가능):
|
||||
- kpi-card: 숫자 + 단위 + 라벨 + 증감 표시
|
||||
- chart: 막대/원형/라인 차트
|
||||
- gauge: 게이지 (목표 대비 달성률)
|
||||
- stat-card: 통계 카드 (건수 + 대기 + 링크)
|
||||
- **표시 모드** (디자이너가 선택):
|
||||
- arrows: 좌우 버튼으로 아이템 넘기기
|
||||
- auto-slide: 전광판처럼 자동 전환 (터치 시 멈춤, 일정 시간 후 재개)
|
||||
- grid: 컴포넌트 영역 내부를 행/열로 쪼개서 여러 아이템 동시 표시 (디자이너가 각 아이템 위치 직접 지정)
|
||||
- scroll: 좌우 또는 상하 스와이프
|
||||
- **데이터**: 각 아이템별 독립 DataSourceConfig (조인/집계 자유)
|
||||
- **계산식 지원**: "생산량/총재고량", "출고량/현재고량" 같은 복합 표현 가능
|
||||
- 값 A, B를 각각 다른 테이블/집계로 설정
|
||||
- 표시 형태: 분수(1,234/5,678), 퍼센트(21.7%), 비율(1,234:5,678)
|
||||
- **CRUD**: 주로 읽기. 목표값 수정 등 필요 시 write 컬럼으로 저장 가능
|
||||
- **이벤트**:
|
||||
- 수신: filter_changed, data_ready
|
||||
- 발행: kpi_clicked (아이템 클릭 시 상세 데이터 전달)
|
||||
- **설정**: 데이터 소스(드롭다운 기반 쉬운 집계), 집계 함수, 계산식, 라벨, 단위, 색상 구간, 차트 타입, 새로고침 주기, 목표값, 표시 모드, 아이템별 보이기/숨기기
|
||||
- **보이기/숨기기**: 각 아이템별로 pop-system에서 개별 on/off 가능 (userConfigurable)
|
||||
- **기존 POP 대시보드 폐기**: `frontend/components/pop/dashboard/` 폴더 전체를 이 컴포넌트로 대체 예정 (Phase 1~3 완료 후)
|
||||
|
||||
#### pop-dashboard 데이터 구조
|
||||
|
||||
```
|
||||
PopDashboardConfig {
|
||||
items: DashboardItem[] // 아이템 목록 (각각 독립 설정)
|
||||
displayMode: "arrows" | "auto-slide" | "grid" | "scroll"
|
||||
autoSlideInterval: number // 자동 슬라이드 간격(초)
|
||||
gridLayout: { columns: number, rows: number } // 행열 그리드 설정
|
||||
showIndicator: boolean // 페이지 인디케이터 표시
|
||||
gap: number // 아이템 간 간격
|
||||
}
|
||||
|
||||
DashboardItem {
|
||||
id: string
|
||||
label: string // pop-system에서 보이기/숨기기용 이름
|
||||
visible: boolean // 보이기/숨기기
|
||||
subType: "kpi-card" | "chart" | "gauge" | "stat-card"
|
||||
dataSource: DataSourceConfig // 각 아이템별 독립 데이터 소스
|
||||
|
||||
// 행열 그리드 모드에서의 위치 (디자이너가 직접 지정)
|
||||
gridPosition: { col: number, row: number, colSpan: number, rowSpan: number }
|
||||
|
||||
// 계산식 (선택사항)
|
||||
formula?: {
|
||||
enabled: boolean
|
||||
values: [
|
||||
{ id: "A", dataSource: DataSourceConfig, label: "생산량" },
|
||||
{ id: "B", dataSource: DataSourceConfig, label: "총재고량" },
|
||||
]
|
||||
expression: string // "A / B", "A + B", "A / B * 100"
|
||||
displayFormat: "value" | "fraction" | "percent" | "ratio"
|
||||
}
|
||||
|
||||
// 서브타입별 설정
|
||||
kpiConfig?: { unit, colorRanges, showTrend, trendPeriod }
|
||||
chartConfig?: { chartType, xAxis, yAxis, colors }
|
||||
gaugeConfig?: { min, max, target, colorRanges }
|
||||
statConfig?: { categories, showLink }
|
||||
}
|
||||
```
|
||||
|
||||
#### 설정 패널 흐름 (드롭다운 기반 쉬운 집계)
|
||||
|
||||
```
|
||||
1. [+ 아이템 추가] 버튼 클릭
|
||||
2. 서브타입 선택: kpi-card / chart / gauge / stat-card
|
||||
3. 데이터 모드 선택: [단일 집계] 또는 [계산식]
|
||||
|
||||
[단일 집계]
|
||||
- 테이블 선택 (table-schema API로 목록)
|
||||
- 조인할 테이블 추가 (선택사항)
|
||||
- 컬럼 선택 → 집계 함수 선택 (합계/건수/평균/최소/최대)
|
||||
- 필터 조건 추가
|
||||
|
||||
[계산식] (예: 생산량/총재고량)
|
||||
- 값 A: 테이블 -> 컬럼 -> 집계함수
|
||||
- 값 B: 테이블 -> 컬럼 -> 집계함수 (다른 테이블도 가능)
|
||||
- 계산식: A / B
|
||||
- 표시 형태: 분수 / 퍼센트 / 비율
|
||||
|
||||
4. 라벨, 단위, 색상 등 외형 설정
|
||||
5. 행열 그리드 위치 설정 (grid 모드일 때)
|
||||
```
|
||||
|
||||
### 3. pop-table (신규 - 가장 복잡)
|
||||
|
||||
- **한 줄 정의**: 데이터 목록을 보여주고 편집함
|
||||
- **카테고리**: display
|
||||
- **역할**: 데이터 목록 표시 + 편집 (카드형/테이블형)
|
||||
- **서브타입**:
|
||||
- card-list: 카드 형태
|
||||
- table-list: 테이블 형태 (행/열 장부)
|
||||
- **데이터**: DataSourceConfig (조인/컬럼별 읽기쓰기 자유)
|
||||
- **CRUD**: useDataSource의 save/update/delete 사용. write/readwrite 컬럼만 자동 추출
|
||||
- **카드 템플릿** (card-list 전용): 카드 내부 미니 그리드로 요소 배치, 요소별 데이터 바인딩
|
||||
- **이벤트**:
|
||||
- 수신: filter_changed, refresh, data_ready
|
||||
- 발행: row_selected, row_action, save_complete, delete_complete
|
||||
- **설정**: 데이터 소스, 표시 모드, 카드 템플릿, 컬럼 정의, 행 선택 방식, 페이징, 정렬, 인라인 편집 여부
|
||||
|
||||
### 4. pop-button (신규)
|
||||
|
||||
- **한 줄 정의**: 누르면 액션 실행 (저장, 삭제 등)
|
||||
- **카테고리**: action
|
||||
- **역할**: 액션 실행 (저장, 삭제, API 호출, 모달 열기 등)
|
||||
- **데이터**: 이벤트로 수신한 데이터를 액션에 활용
|
||||
- **CRUD**: 버튼 클릭 시 수신 데이터 기반으로 save/update/delete 실행
|
||||
- **이벤트**:
|
||||
- 수신: data_ready, row_selected
|
||||
- 발행: save_complete, delete_complete 등
|
||||
- **설정**: 라벨, 아이콘, 크기, 스타일, 액션 설정(PopActionConfig), 확인 다이얼로그, 로딩 상태
|
||||
|
||||
### 5. pop-icon (신규)
|
||||
|
||||
- **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음)
|
||||
- **카테고리**: action
|
||||
- **역할**: 네비게이션 (화면 이동, URL 이동)
|
||||
- **데이터**: 없음
|
||||
- **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행)
|
||||
- **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시
|
||||
- **pop-lookup과의 차이**: pop-icon은 이동/실행만 함. 값을 선택해서 돌려주지 않음
|
||||
|
||||
### 6. pop-search (신규)
|
||||
|
||||
- **한 줄 정의**: 조건을 입력해서 다른 컴포넌트를 조회/필터링
|
||||
- **카테고리**: input
|
||||
- **역할**: 다른 컴포넌트에 필터 조건 전달 + 자체 데이터 조회
|
||||
- **서브타입**:
|
||||
- text-search: 텍스트 검색
|
||||
- date-range: 날짜 범위
|
||||
- select-filter: 드롭다운 선택 (공통코드 연동)
|
||||
- combo-filter: 복합 필터 (여러 조건 조합)
|
||||
- **실행 방식**: auto(값 변경 즉시) 또는 button(검색 버튼 클릭 시)
|
||||
- **데이터**: 공통코드/카테고리 API로 선택 항목 조회
|
||||
- **이벤트**:
|
||||
- 수신: 없음
|
||||
- 발행: filter_changed (필터 값 변경 시)
|
||||
- **설정**: 필터 타입, 대상 컬럼, 공통코드 연결, 플레이스홀더, 실행 방식(auto/button), 발행할 이벤트 이름
|
||||
- **pop-field와의 차이**: pop-search 입력값은 조회용(DB에 안 들어감). pop-field 입력값은 저장용(DB에 들어감)
|
||||
|
||||
### 7. pop-field (신규)
|
||||
|
||||
- **한 줄 정의**: 저장할 값을 입력
|
||||
- **카테고리**: input
|
||||
- **역할**: 단일 데이터 입력 (폼 필드) - 입력한 값이 DB에 저장되는 것이 목적
|
||||
- **서브타입**:
|
||||
- text: 텍스트 입력
|
||||
- number: 숫자 입력 (수량, 금액)
|
||||
- date: 날짜 선택
|
||||
- select: 드롭다운 선택
|
||||
- numpad: 큰 숫자패드 (현장용)
|
||||
- **데이터**: DataSourceConfig (선택적)
|
||||
- select 옵션을 DB에서 조회 가능
|
||||
- ColumnBinding으로 입력값의 저장 대상 테이블/컬럼 지정
|
||||
- **CRUD**: 자체 저장은 보통 하지 않음. value_changed 이벤트로 pop-button 등에 전달
|
||||
- **이벤트**:
|
||||
- 수신: set_value (외부에서 값 설정)
|
||||
- 발행: value_changed (값 + 컬럼명 + 모드 정보)
|
||||
- **설정**: 입력 타입, 라벨, 플레이스홀더, 필수 여부, 유효성 검증, 최소/최대값, 단위 표시, 바인딩 컬럼
|
||||
|
||||
### 8. pop-lookup (신규)
|
||||
|
||||
- **한 줄 정의**: 모달에서 값을 골라서 반환
|
||||
- **카테고리**: input
|
||||
- **역할**: 필드를 클릭하면 모달이 열리고, 목록에서 선택하면 값이 반환되는 컴포넌트
|
||||
- **서브타입 (모달 안 표시 방식)**:
|
||||
- card: 카드형 목록
|
||||
- table: 테이블형 목록
|
||||
- icon-grid: 아이콘 그리드 (참조 화면의 거래처 선택처럼)
|
||||
- **동작 흐름**: 필드 클릭 -> 모달 열림 -> 목록에서 선택 -> 모달 닫힘 -> 필드에 값 표시 + 이벤트 발행
|
||||
- **데이터**: DataSourceConfig (모달 안 목록의 데이터 소스)
|
||||
- **이벤트**:
|
||||
- 수신: set_value (외부에서 값 초기화)
|
||||
- 발행: value_selected (선택한 레코드 전체 데이터 전달), filter_changed (선택 값을 필터로 전달)
|
||||
- **설정**: 라벨, 플레이스홀더, 데이터 소스, 모달 표시 방식(card/table/icon-grid), 표시 컬럼(모달 목록에 보여줄 컬럼), 반환 컬럼(선택 시 돌려줄 값), 발행할 이벤트 이름
|
||||
- **pop-icon과의 차이**: pop-icon은 이동/실행만 하고 값이 안 돌아옴. pop-lookup은 값을 골라서 돌려줌
|
||||
- **pop-search와의 차이**: pop-search는 텍스트/날짜/드롭다운으로 필터링. pop-lookup은 모달을 열어서 목록에서 선택
|
||||
|
||||
#### pop-lookup 모달 화면 설계 방식
|
||||
|
||||
pop-lookup이 열리는 모달의 내부 화면은 **두 가지 방식** 중 선택할 수 있다:
|
||||
|
||||
**방식 A: 인라인 모달 (기본)**
|
||||
- pop-lookup 컴포넌트의 설정 패널에서 직접 모달 내부 화면을 구성
|
||||
- DataSourceConfig + 표시 컬럼 + 검색 필터 설정만으로 동작
|
||||
- 별도 화면 생성 없이 컴포넌트 설정만으로 완결
|
||||
- 적합한 경우: 단순 목록 선택 (거래처 목록, 품목 목록 등)
|
||||
|
||||
**방식 B: 외부 화면 참조 (고급)**
|
||||
- 별도의 POP 화면(screen_id)을 모달로 연결
|
||||
- 모달 안에서 검색/필터/테이블 등 복잡한 화면을 디자이너로 자유롭게 구성
|
||||
- 여러 pop-lookup에서 같은 모달 화면을 재사용 가능
|
||||
- 적합한 경우: 복잡한 검색/필터가 필요한 선택 화면, 여러 화면에서 공유하는 모달
|
||||
|
||||
**설정 구조:**
|
||||
|
||||
```
|
||||
modalConfig: {
|
||||
mode: "inline" | "screen-ref"
|
||||
|
||||
// mode = "inline"일 때 사용
|
||||
dataSource: DataSourceConfig
|
||||
displayColumns: ColumnBinding[]
|
||||
searchFilter: { enabled: boolean, targetColumns: string[] }
|
||||
modalSize: { width: number, height: number }
|
||||
|
||||
// mode = "screen-ref"일 때 사용
|
||||
screenId: number // 참조할 POP 화면 ID
|
||||
returnMapping: { // 모달 화면에서 선택된 값을 어떻게 매핑할지
|
||||
sourceColumn: string // 모달 화면에서 반환하는 컬럼
|
||||
targetField: string // pop-lookup 필드에 표시할 값
|
||||
}[]
|
||||
modalSize: { width: number, height: number }
|
||||
}
|
||||
```
|
||||
|
||||
**기존 시스템과의 호환성 (검증 완료):**
|
||||
|
||||
| 항목 | 현재 상태 | pop-lookup 지원 여부 |
|
||||
|------|-----------|---------------------|
|
||||
| DB: layout_data JSONB | 유연한 JSON 구조 | modalConfig를 layout_data에 저장 가능 (스키마 변경 불필요) |
|
||||
| DB: screen_layouts_pop 테이블 | screen_id + company_code 기반 | 모달 화면도 별도 screen_id로 저장 가능 |
|
||||
| 프론트: TabsWidget | screenId로 외부 화면 참조 지원 | 같은 패턴으로 모달에서 외부 화면 로드 가능 |
|
||||
| 프론트: detectLinkedModals API | 연결된 모달 화면 감지 기능 있음 | 화면 간 참조 관계 추적에 활용 가능 |
|
||||
| 백엔드: saveLayoutPop/getLayoutPop | POP 전용 저장/조회 API 있음 | 모달 화면도 동일 API로 저장/조회 가능 |
|
||||
| 레이어 시스템 | layer_id 기반 다중 레이어 지원 | 모달 내부 레이아웃을 레이어로 관리 가능 |
|
||||
|
||||
**DB 마이그레이션 불필요**: layout_data가 JSONB이므로 modalConfig를 컴포넌트 overrides에 포함하면 됨.
|
||||
**백엔드 변경 불필요**: 기존 saveLayoutPop/getLayoutPop API가 그대로 사용 가능.
|
||||
**프론트엔드 참고 패턴**: TabsWidget의 screenId 참조 방식을 그대로 차용.
|
||||
|
||||
### 9. pop-system (신규)
|
||||
|
||||
- **한 줄 정의**: 시스템 설정을 하나로 통합한 컴포넌트 (프로필, 테마, 보이기/숨기기)
|
||||
- **카테고리**: system
|
||||
- **역할**: 사용자 개인 설정 기능을 제공하는 통합 컴포넌트
|
||||
- **내부 포함 기능**:
|
||||
- 프로필 표시 (사용자명, 부서)
|
||||
- 테마 선택 (기본/다크/블루/그린)
|
||||
- 대시보드 보이기/숨기기 체크박스 (같은 화면의 userConfigurable=true 컴포넌트를 자동 수집)
|
||||
- 하단 메뉴 보이기/숨기기
|
||||
- 드래그앤드롭으로 순서 변경
|
||||
- **디자이너가 설정하는 것**: 크기(그리드에서 차지하는 영역), 내부 라벨/아이콘 크기와 위치
|
||||
- **사용자가 하는 것**: 체크박스로 컴포넌트 보이기/숨기기, 테마 선택, 순서 변경
|
||||
- **데이터**: 같은 화면의 layout_data에서 컴포넌트 목록을 자동 수집
|
||||
- **저장**: 사용자별 설정을 localStorage에 저장 (데스크탑 패턴 따름)
|
||||
- **이벤트**:
|
||||
- 수신: 없음
|
||||
- 발행: visibility_changed (컴포넌트 보이기/숨기기 변경 시), theme_changed (테마 변경 시)
|
||||
- **설정**: 내부 라벨 크기, 아이콘 크기, 위치 정도만
|
||||
- **특이사항**:
|
||||
- 디자이너가 이 컴포넌트를 배치하지 않으면 해당 화면에 개인 설정 기능이 없다
|
||||
- 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정하는 구조
|
||||
- 메인 홈에는 배치, 업무 화면(입고 등)에는 안 배치하는 식으로 사용
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 간 통신 예시
|
||||
|
||||
### 예시 1: 검색 -> 필터 연동
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Search as pop-search
|
||||
participant Dashboard as pop-dashboard
|
||||
participant Table as pop-table
|
||||
|
||||
Note over Search: 사용자가 창고 WH01 선택
|
||||
Search->>Dashboard: filter_changed
|
||||
Search->>Table: filter_changed
|
||||
Note over Dashboard: DataSource 재조회
|
||||
Note over Table: DataSource 재조회
|
||||
```
|
||||
|
||||
### 예시 2: 데이터 전달 + 선택적 저장
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Table as pop-table
|
||||
participant Field as pop-field
|
||||
participant Button as pop-button
|
||||
|
||||
Note over Table: 사용자가 발주 행 선택
|
||||
Table->>Field: row_selected
|
||||
Table->>Button: row_selected
|
||||
Note over Field: 사용자가 qty를 500으로 입력
|
||||
Field->>Button: value_changed
|
||||
Note over Button: 사용자가 저장 클릭
|
||||
Note over Button: write/readwrite 컬럼만 추출하여 저장
|
||||
Button->>Table: save_complete
|
||||
Note over Table: 데이터 새로고침
|
||||
```
|
||||
|
||||
### 예시 3: pop-lookup 거래처 선택 -> 품목 조회
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Lookup as pop-lookup
|
||||
participant Table as pop-table
|
||||
|
||||
Note over Lookup: 사용자가 거래처 필드 클릭
|
||||
Note over Lookup: 모달 열림 - 거래처 목록 표시
|
||||
Note over Lookup: 사용자가 대한금속 선택
|
||||
Note over Lookup: 모달 닫힘 - 필드에 대한금속 표시
|
||||
Lookup->>Table: filter_changed { company: "대한금속" }
|
||||
Note over Table: company=대한금속 필터로 재조회
|
||||
Note over Table: 발주 품목 3건 표시
|
||||
```
|
||||
|
||||
### 예시 4: pop-lookup 인라인 모달 vs 외부 화면 참조
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 사용자
|
||||
participant Lookup as pop-lookup (거래처)
|
||||
participant Modal as 모달
|
||||
|
||||
Note over User,Modal: [방식 A: 인라인 모달]
|
||||
User->>Lookup: 거래처 필드 클릭
|
||||
Lookup->>Modal: 인라인 모달 열림 (DataSourceConfig 기반)
|
||||
Note over Modal: supplier 테이블에서 목록 조회
|
||||
Note over Modal: 테이블형 목록 표시
|
||||
User->>Modal: "대한금속" 선택
|
||||
Modal->>Lookup: value_selected { supplier_code: "DH001", name: "대한금속" }
|
||||
Note over Lookup: 필드에 "대한금속" 표시
|
||||
|
||||
Note over User,Modal: [방식 B: 외부 화면 참조]
|
||||
User->>Lookup: 거래처 필드 클릭
|
||||
Lookup->>Modal: 모달 열림 (screenId=42 화면 로드)
|
||||
Note over Modal: 별도 POP 화면 렌더링
|
||||
Note over Modal: pop-search(검색) + pop-table(목록) 등 배치된 컴포넌트 동작
|
||||
User->>Modal: 검색 후 "대한금속" 선택
|
||||
Modal->>Lookup: returnMapping 기반으로 값 반환
|
||||
Note over Lookup: 필드에 "대한금속" 표시
|
||||
```
|
||||
|
||||
### 예시 5: 컬럼별 읽기/쓰기 분리 동작
|
||||
|
||||
5개 컬럼이 있는 발주 화면:
|
||||
|
||||
- item_code (read) -> 화면에 표시, 저장 안 함
|
||||
- item_name (read, 조인) -> item_info 테이블에서 가져옴, 저장 안 함
|
||||
- inbound_qty (readwrite) -> 화면에 표시 + 사용자 수정 + 저장
|
||||
- warehouse (write) -> 사용자 입력 + 저장
|
||||
- memo (write) -> 사용자 입력 + 저장
|
||||
|
||||
저장 API 호출 시: `{ inbound_qty: 500, warehouse: "WH01", memo: "긴급" }` 만 전달
|
||||
조회 API 호출 시: 5개 컬럼 전부 + 조인된 item_name까지 조회
|
||||
|
||||
---
|
||||
|
||||
## 구현 우선순위
|
||||
|
||||
- Phase 0 (공통 인프라): ColumnBinding, JoinConfig, DataSourceConfig 타입, useDataSource 훅 (CRUD 포함), usePopEvent 훅 (데이터 전달 포함), PopActionConfig 타입
|
||||
- Phase 1 (기본 표시): pop-dashboard (4개 서브타입 전부 + 멀티 아이템 컨테이너 + 4개 표시 모드 + 계산식)
|
||||
- Phase 2 (기본 액션): pop-button, pop-icon
|
||||
- Phase 3 (데이터 목록): pop-table (테이블형부터, 카드형은 후순위)
|
||||
- Phase 4 (입력/연동): pop-search, pop-field, pop-lookup
|
||||
- Phase 5 (고도화): pop-table 카드 템플릿
|
||||
- Phase 6 (시스템): pop-system (프로필, 테마, 대시보드 보이기/숨기기 통합)
|
||||
|
||||
### Phase 1 상세 변경 (2026-02-09 토의 결정)
|
||||
|
||||
기존 계획에서 "KPI 카드 우선"이었으나, 토의 결과 **4개 서브타입 전부를 Phase 1에서 구현**으로 변경:
|
||||
- kpi-card, chart, gauge, stat-card 모두 Phase 1
|
||||
- 멀티 아이템 컨테이너 (arrows, auto-slide, grid, scroll)
|
||||
- 계산식 지원 (formula)
|
||||
- 드롭다운 기반 쉬운 집계 설정
|
||||
- 기존 `frontend/components/pop/dashboard/` 폴더는 Phase 1 완료 후 폐기/삭제
|
||||
|
||||
### 백엔드 API 현황 (호환성 점검 완료)
|
||||
|
||||
기존 백엔드에 이미 구현되어 있어 새로 만들 필요 없는 API:
|
||||
|
||||
| API | 용도 | 비고 |
|
||||
|-----|------|------|
|
||||
| `dataApi.getTableData()` | 동적 테이블 조회 | 페이징, 검색, 정렬, 필터 |
|
||||
| `dataApi.getJoinedData()` | 2개 테이블 조인 | Entity 조인, 필터링, 중복제거 |
|
||||
| `entityJoinApi.getTableDataWithJoins()` | Entity 조인 전용 | ID->이름 자동 변환 |
|
||||
| `dataApi.createRecord/updateRecord/deleteRecord()` | 동적 CRUD | - |
|
||||
| `dataApi.upsertGroupedRecords()` | 그룹 UPSERT | - |
|
||||
| `dashboardApi.executeQuery()` | SELECT SQL 직접 실행 | 집계/복합조인용 |
|
||||
| `dashboardApi.getTableSchema()` | 테이블/컬럼 목록 | 설정 패널 드롭다운용 |
|
||||
|
||||
**백엔드 신규 개발 불필요** - 기존 API만으로 모든 데이터 연동 가능
|
||||
|
||||
### useDataSource의 API 선택 전략
|
||||
|
||||
```
|
||||
단순 조회 (조인/집계 없음) -> dataApi.getTableData() 또는 entityJoinApi
|
||||
2개 테이블 조인 -> dataApi.getJoinedData()
|
||||
3개+ 테이블 조인 또는 집계 -> DataSourceConfig를 SQL로 변환 -> dashboardApi.executeQuery()
|
||||
CRUD -> dataApi.createRecord/updateRecord/deleteRecord()
|
||||
```
|
||||
|
||||
### POP 전용 훅 분리 (2026-02-09 결정)
|
||||
|
||||
데스크탑과의 완전 분리를 위해 POP 전용 훅은 별도 폴더:
|
||||
- `frontend/hooks/pop/usePopEvent.ts` (POP 전용)
|
||||
- `frontend/hooks/pop/useDataSource.ts` (POP 전용)
|
||||
|
||||
## 기존 시스템 호환성 검증 결과 (v8.0 추가)
|
||||
|
||||
v8.0에서 추가된 모달 설계 방식에 대해 기존 시스템과의 호환성을 검증한 결과:
|
||||
|
||||
### DB 스키마 (변경 불필요)
|
||||
|
||||
| 테이블 | 현재 구조 | 호환성 |
|
||||
|--------|-----------|--------|
|
||||
| screen_layouts_v2 | layout_data JSONB + screen_id + company_code + layer_id | modalConfig를 컴포넌트 overrides에 포함하면 됨 |
|
||||
| screen_layouts_pop | 동일 구조 (POP 전용) | 모달 화면도 별도 screen_id로 저장 가능 |
|
||||
|
||||
- layout_data가 JSONB 타입이므로 어떤 JSON 구조든 저장 가능
|
||||
- 모달 화면을 별도 screen_id로 만들어도 기존 UNIQUE(screen_id, company_code, layer_id) 제약조건과 충돌 없음
|
||||
- DB 마이그레이션 불필요
|
||||
|
||||
### 백엔드 API (변경 불필요)
|
||||
|
||||
| API | 엔드포인트 | 호환성 |
|
||||
|-----|-----------|--------|
|
||||
| POP 레이아웃 저장 | POST /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 저장 |
|
||||
| POP 레이아웃 조회 | GET /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 조회 |
|
||||
| 연결 모달 감지 | detectLinkedModals(screenId) | 화면 간 참조 관계 추적에 활용 |
|
||||
|
||||
### 프론트엔드 (참고 패턴 존재)
|
||||
|
||||
| 기존 기능 | 위치 | 활용 방안 |
|
||||
|-----------|------|-----------|
|
||||
| TabsWidget screenId 참조 | frontend/components/screen/widgets/TabsWidget.tsx | 같은 패턴으로 모달에서 외부 화면 로드 |
|
||||
| TabsConfigPanel | frontend/components/screen/config-panels/TabsConfigPanel.tsx | pop-lookup 설정 패널의 모달 화면 선택 UI 참조 |
|
||||
| ScreenDesigner 탭 내부 컴포넌트 | frontend/components/screen/ScreenDesigner.tsx | 모달 내부 컴포넌트 편집 패턴 참조 |
|
||||
|
||||
### 결론
|
||||
|
||||
- DB 마이그레이션: 불필요
|
||||
- 백엔드 변경: 불필요
|
||||
- 프론트엔드: pop-lookup 컴포넌트 구현 시 기존 TabsWidget의 screenId 참조 패턴을 그대로 차용
|
||||
- 새로운 API: 불필요 (기존 saveLayoutPop/getLayoutPop로 충분)
|
||||
|
||||
## 참고 파일
|
||||
|
||||
- 레지스트리: `frontend/lib/registry/PopComponentRegistry.ts`
|
||||
- 기존 텍스트 컴포넌트: `frontend/lib/registry/pop-components/pop-text.tsx`
|
||||
- 공통 스타일 타입: `frontend/lib/registry/pop-components/types.ts`
|
||||
- POP 타입 정의: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||
- 기존 스펙 (v4): `popdocs/components-spec.md`
|
||||
- 탭 위젯 (모달 참조 패턴): `frontend/components/screen/widgets/TabsWidget.tsx`
|
||||
- POP 레이아웃 API: `frontend/lib/api/screen.ts` (saveLayoutPop, getLayoutPop)
|
||||
- 백엔드 화면관리: `backend-node/src/controllers/screenManagementController.ts`
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# 프로젝트 상태 추적
|
||||
|
||||
> **최종 업데이트**: 2026-02-11
|
||||
|
||||
---
|
||||
|
||||
## 현재 진행 중
|
||||
|
||||
### pop-dashboard 스타일 정리
|
||||
**상태**: 코딩 완료, 브라우저 확인 대기
|
||||
**계획서**: [popdocs/PLAN.md](./popdocs/PLAN.md)
|
||||
**내용**: 글자 크기 커스텀 제거 + 라벨 정렬만 유지 + stale closure 수정
|
||||
|
||||
---
|
||||
|
||||
## 다음 작업
|
||||
|
||||
| 순서 | 작업 | 상태 |
|
||||
|------|------|------|
|
||||
| 1 | 브라우저 확인 (라벨 정렬 동작, 반응형 자동 크기, 미리보기 반영) | [ ] 대기 |
|
||||
| 2 | 게이지/통계카드 테스트 시나리오 동작 확인 | [ ] 대기 |
|
||||
| 3 | Phase 2 계획 수립 (pop-button, pop-icon) | [ ] 대기 |
|
||||
|
||||
---
|
||||
|
||||
## 완료된 작업 (최근)
|
||||
|
||||
| 날짜 | 작업 | 비고 |
|
||||
|------|------|------|
|
||||
| 2026-02-11 | 대시보드 스타일 정리 | FONT_SIZE_PX/글자 크기 Select 삭제, ItemStyleConfig -> labelAlign만, stale closure 수정 |
|
||||
| 2026-02-10 | 디자이너 캔버스 UX 개선 | 헤더 제거, 실제 데이터 렌더링, 컴포넌트 목록 |
|
||||
| 2026-02-10 | 차트/게이지/네비게이션/정렬 디자인 개선 | CartesianGrid, abbreviateNumber, 오버레이 화살표/인디케이터 |
|
||||
| 2026-02-10 | 대시보드 4가지 아이템 모드 완성 | groupBy UI, xAxisColumn, 통계카드 카테고리, 필터 버그 수정 |
|
||||
| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 |
|
||||
| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent |
|
||||
| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 |
|
||||
|
||||
---
|
||||
|
||||
## 알려진 이슈
|
||||
|
||||
| # | 이슈 | 심각도 | 상태 |
|
||||
|---|------|--------|------|
|
||||
| 1 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 |
|
||||
| 2 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 |
|
||||
| 3 | 기존 저장 데이터의 `itemStyle.align`이 `labelAlign`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 |
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# PLM System Backend - Node.js + TypeScript
|
||||
re# PLM System Backend - Node.js + TypeScript
|
||||
|
||||
Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백엔드입니다.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
## ⚠️ 문서 사용 안내
|
||||
|
||||
> **이 문서는 "품목정보" 화면의 구현 예시입니다.**
|
||||
|
||||
>
|
||||
> ### 📌 중요: JSON 데이터는 참고용입니다!
|
||||
>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ import {
|
|||
GRID_BREAKPOINTS,
|
||||
detectGridMode,
|
||||
} from "@/components/pop/designer/types/pop-layout";
|
||||
import PopRenderer from "@/components/pop/designer/renderers/PopRenderer";
|
||||
// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import)
|
||||
import "@/lib/registry/pop-components";
|
||||
import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals";
|
||||
import {
|
||||
useResponsiveModeWithOverride,
|
||||
type DeviceType,
|
||||
|
|
@ -144,6 +146,28 @@ function PopScreenViewPage() {
|
|||
}
|
||||
}, [screenId]);
|
||||
|
||||
// 뷰어 모드에서도 컴포넌트 크기 변경 지원 (더보기 등)
|
||||
const handleRequestResize = React.useCallback((componentId: string, newRowSpan: number, newColSpan?: number) => {
|
||||
setLayout((prev) => {
|
||||
const comp = prev.components[componentId];
|
||||
if (!comp) return prev;
|
||||
return {
|
||||
...prev,
|
||||
components: {
|
||||
...prev.components,
|
||||
[componentId]: {
|
||||
...comp,
|
||||
position: {
|
||||
...comp.position,
|
||||
rowSpan: newRowSpan,
|
||||
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"];
|
||||
const hasComponents = Object.keys(layout.components).length > 0;
|
||||
|
||||
|
|
@ -180,7 +204,7 @@ function PopScreenViewPage() {
|
|||
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
|
||||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div className="h-screen bg-gray-100 flex flex-col overflow-hidden">
|
||||
<div className="h-screen bg-gray-100 flex flex-col">
|
||||
{/* 상단 툴바 (프리뷰 모드에서만) */}
|
||||
{isPreviewMode && (
|
||||
<div className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
||||
|
|
@ -261,7 +285,7 @@ function PopScreenViewPage() {
|
|||
)}
|
||||
|
||||
{/* POP 화면 컨텐츠 */}
|
||||
<div className={`flex-1 flex flex-col ${isPreviewMode ? "py-4 overflow-auto items-center" : ""}`}>
|
||||
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : ""}`}>
|
||||
{/* 현재 모드 표시 (일반 모드) */}
|
||||
{!isPreviewMode && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||
|
|
@ -270,7 +294,7 @@ function PopScreenViewPage() {
|
|||
)}
|
||||
|
||||
<div
|
||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full"}`}
|
||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`}
|
||||
style={isPreviewMode ? {
|
||||
width: currentDevice.width,
|
||||
maxHeight: "80vh",
|
||||
|
|
@ -292,13 +316,15 @@ function PopScreenViewPage() {
|
|||
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
|
||||
|
||||
return (
|
||||
<PopRenderer
|
||||
<PopViewerWithModals
|
||||
layout={layout}
|
||||
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
|
||||
screenId={String(screenId)}
|
||||
currentMode={currentModeKey}
|
||||
isDesignMode={false}
|
||||
overrideGap={adjustedGap}
|
||||
overridePadding={adjustedPadding}
|
||||
onRequestResize={handleRequestResize}
|
||||
currentScreenId={screenId}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,12 @@ import {
|
|||
GAP_PRESETS,
|
||||
GRID_BREAKPOINTS,
|
||||
DEFAULT_COMPONENT_GRID_SIZE,
|
||||
PopModalDefinition,
|
||||
ModalSizePreset,
|
||||
MODAL_SIZE_PRESETS,
|
||||
resolveModalWidth,
|
||||
} from "./types/pop-layout";
|
||||
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff } from "lucide-react";
|
||||
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -112,6 +116,16 @@ interface PopCanvasProps {
|
|||
onLockLayout?: () => void;
|
||||
onResetOverride?: (mode: GridMode) => void;
|
||||
onChangeGapPreset?: (preset: GapPreset) => void;
|
||||
/** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */
|
||||
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||
/** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */
|
||||
previewPageIndex?: number;
|
||||
/** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */
|
||||
activeCanvasId?: string;
|
||||
/** 캔버스 전환 콜백 */
|
||||
onActiveCanvasChange?: (canvasId: string) => void;
|
||||
/** 모달 정의 업데이트 콜백 */
|
||||
onUpdateModal?: (modalId: string, updates: Partial<PopModalDefinition>) => void;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
@ -135,7 +149,43 @@ export default function PopCanvas({
|
|||
onLockLayout,
|
||||
onResetOverride,
|
||||
onChangeGapPreset,
|
||||
onRequestResize,
|
||||
previewPageIndex,
|
||||
activeCanvasId = "main",
|
||||
onActiveCanvasChange,
|
||||
onUpdateModal,
|
||||
}: PopCanvasProps) {
|
||||
// 모달 탭 데이터
|
||||
const modalTabs = useMemo(() => {
|
||||
const tabs: { id: string; label: string }[] = [{ id: "main", label: "메인화면" }];
|
||||
if (layout.modals?.length) {
|
||||
for (const modal of layout.modals) {
|
||||
const numbering = modal.id.replace("modal-", "");
|
||||
tabs.push({ id: modal.id, label: `모달화면 ${numbering}` });
|
||||
}
|
||||
}
|
||||
return tabs;
|
||||
}, [layout.modals]);
|
||||
|
||||
// activeCanvasId에 따라 렌더링할 layout 분기
|
||||
const activeLayout = useMemo((): PopLayoutDataV5 => {
|
||||
if (activeCanvasId === "main") return layout;
|
||||
const modal = layout.modals?.find(m => m.id === activeCanvasId);
|
||||
if (!modal) return layout; // fallback
|
||||
return {
|
||||
...layout,
|
||||
gridConfig: modal.gridConfig,
|
||||
components: modal.components,
|
||||
overrides: modal.overrides,
|
||||
};
|
||||
}, [layout, activeCanvasId]);
|
||||
|
||||
// 현재 활성 모달 정의 (모달 캔버스일 때만)
|
||||
const activeModal = useMemo(() => {
|
||||
if (activeCanvasId === "main") return null;
|
||||
return layout.modals?.find(m => m.id === activeCanvasId) || null;
|
||||
}, [layout.modals, activeCanvasId]);
|
||||
|
||||
// 줌 상태
|
||||
const [canvasScale, setCanvasScale] = useState(0.8);
|
||||
|
||||
|
|
@ -162,12 +212,12 @@ export default function PopCanvas({
|
|||
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
|
||||
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
|
||||
|
||||
// 숨김 컴포넌트 ID 목록
|
||||
const hiddenComponentIds = layout.overrides?.[currentMode]?.hidden || [];
|
||||
// 숨김 컴포넌트 ID 목록 (activeLayout 기반)
|
||||
const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || [];
|
||||
|
||||
// 동적 캔버스 높이 계산 (컴포넌트 배치 기반)
|
||||
const dynamicCanvasHeight = useMemo(() => {
|
||||
const visibleComps = Object.values(layout.components).filter(
|
||||
const visibleComps = Object.values(activeLayout.components).filter(
|
||||
comp => !hiddenComponentIds.includes(comp.id)
|
||||
);
|
||||
|
||||
|
|
@ -186,7 +236,7 @@ export default function PopCanvas({
|
|||
const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2;
|
||||
|
||||
return Math.max(MIN_CANVAS_HEIGHT, height);
|
||||
}, [layout.components, layout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]);
|
||||
}, [activeLayout.components, activeLayout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]);
|
||||
|
||||
// 그리드 라벨 계산 (동적 행 수)
|
||||
const gridLabels = useMemo(() => {
|
||||
|
|
@ -300,7 +350,7 @@ export default function PopCanvas({
|
|||
};
|
||||
|
||||
// 현재 모드에서의 유효 위치들로 중첩 검사
|
||||
const effectivePositions = getAllEffectivePositions(layout, currentMode);
|
||||
const effectivePositions = getAllEffectivePositions(activeLayout, currentMode);
|
||||
const existingPositions = Array.from(effectivePositions.values());
|
||||
|
||||
const hasOverlap = existingPositions.some(pos =>
|
||||
|
|
@ -346,7 +396,7 @@ export default function PopCanvas({
|
|||
const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean };
|
||||
|
||||
// 현재 모드에서의 유효 위치들 가져오기
|
||||
const effectivePositions = getAllEffectivePositions(layout, currentMode);
|
||||
const effectivePositions = getAllEffectivePositions(activeLayout, currentMode);
|
||||
|
||||
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
|
||||
// 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
|
||||
|
|
@ -398,42 +448,42 @@ export default function PopCanvas({
|
|||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
}),
|
||||
[onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding]
|
||||
[onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, activeLayout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding]
|
||||
);
|
||||
|
||||
drop(canvasRef);
|
||||
|
||||
// 빈 상태 체크
|
||||
const isEmpty = Object.keys(layout.components).length === 0;
|
||||
// 빈 상태 체크 (activeLayout 기반)
|
||||
const isEmpty = Object.keys(activeLayout.components).length === 0;
|
||||
|
||||
// 숨김 처리된 컴포넌트 객체 목록 (hiddenComponentIds는 라인 166에서 정의됨)
|
||||
// 숨김 처리된 컴포넌트 객체 목록
|
||||
const hiddenComponents = useMemo(() => {
|
||||
return hiddenComponentIds
|
||||
.map(id => layout.components[id])
|
||||
.map(id => activeLayout.components[id])
|
||||
.filter(Boolean);
|
||||
}, [hiddenComponentIds, layout.components]);
|
||||
}, [hiddenComponentIds, activeLayout.components]);
|
||||
|
||||
// 표시되는 컴포넌트 목록 (숨김 제외)
|
||||
const visibleComponents = useMemo(() => {
|
||||
return Object.values(layout.components).filter(
|
||||
return Object.values(activeLayout.components).filter(
|
||||
comp => !hiddenComponentIds.includes(comp.id)
|
||||
);
|
||||
}, [layout.components, hiddenComponentIds]);
|
||||
}, [activeLayout.components, hiddenComponentIds]);
|
||||
|
||||
// 검토 필요 컴포넌트 목록
|
||||
const reviewComponents = useMemo(() => {
|
||||
return visibleComponents.filter(comp => {
|
||||
const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id];
|
||||
const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id];
|
||||
return needsReview(currentMode, hasOverride);
|
||||
});
|
||||
}, [visibleComponents, layout.overrides, currentMode]);
|
||||
}, [visibleComponents, activeLayout.overrides, currentMode]);
|
||||
|
||||
// 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때)
|
||||
const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0;
|
||||
|
||||
// 12칸 모드가 아닐 때만 패널 표시
|
||||
// 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시
|
||||
const hasGridComponents = Object.keys(layout.components).length > 0;
|
||||
const hasGridComponents = Object.keys(activeLayout.components).length > 0;
|
||||
const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
|
||||
const showRightPanel = showReviewPanel || showHiddenPanel;
|
||||
|
||||
|
|
@ -573,6 +623,32 @@ export default function PopCanvas({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 모달 탭 바 (모달이 1개 이상 있을 때만 표시) */}
|
||||
{modalTabs.length > 1 && (
|
||||
<div className="flex gap-1 border-b bg-muted/30 px-4 py-1">
|
||||
{modalTabs.map(tab => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
variant={activeCanvasId === tab.id ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onActiveCanvasChange?.(tab.id)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 사이즈 설정 패널 (모달 캔버스 활성 시) */}
|
||||
{activeModal && (
|
||||
<ModalSizeSettingsPanel
|
||||
modal={activeModal}
|
||||
currentMode={currentMode}
|
||||
onUpdate={(updates) => onUpdateModal?.(activeModal.id, updates)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 캔버스 영역 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -677,7 +753,7 @@ export default function PopCanvas({
|
|||
) : (
|
||||
// 그리드 렌더러
|
||||
<PopRenderer
|
||||
layout={layout}
|
||||
layout={activeLayout}
|
||||
viewportWidth={customWidth}
|
||||
currentMode={currentMode}
|
||||
isDesignMode={true}
|
||||
|
|
@ -688,8 +764,10 @@ export default function PopCanvas({
|
|||
onComponentMove={onMoveComponent}
|
||||
onComponentResize={onResizeComponent}
|
||||
onComponentResizeEnd={onResizeEnd}
|
||||
onRequestResize={onRequestResize}
|
||||
overrideGap={adjustedGap}
|
||||
overridePadding={adjustedPadding}
|
||||
previewPageIndex={previewPageIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -969,3 +1047,278 @@ function HiddenItem({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 모달 사이즈 설정 패널
|
||||
// ========================================
|
||||
|
||||
const SIZE_PRESET_ORDER: ModalSizePreset[] = ["sm", "md", "lg", "xl", "full"];
|
||||
|
||||
const MODE_LABELS: { mode: GridMode; label: string; icon: typeof Smartphone; width: number }[] = [
|
||||
{ mode: "mobile_portrait", label: "모바일 세로", icon: Smartphone, width: 375 },
|
||||
{ mode: "mobile_landscape", label: "모바일 가로", icon: Smartphone, width: 667 },
|
||||
{ mode: "tablet_portrait", label: "태블릿 세로", icon: Tablet, width: 768 },
|
||||
{ mode: "tablet_landscape", label: "태블릿 가로", icon: Monitor, width: 1024 },
|
||||
];
|
||||
|
||||
function ModalSizeSettingsPanel({
|
||||
modal,
|
||||
currentMode,
|
||||
onUpdate,
|
||||
}: {
|
||||
modal: PopModalDefinition;
|
||||
currentMode: GridMode;
|
||||
onUpdate: (updates: Partial<PopModalDefinition>) => void;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const sizeConfig = modal.sizeConfig || { default: "md" };
|
||||
const usePerMode = !!sizeConfig.modeOverrides && Object.keys(sizeConfig.modeOverrides).length > 0;
|
||||
|
||||
const currentModeInfo = MODE_LABELS.find(m => m.mode === currentMode)!;
|
||||
const currentModeWidth = currentModeInfo.width;
|
||||
const currentModalWidth = resolveModalWidth(
|
||||
{ default: sizeConfig.default, modeOverrides: sizeConfig.modeOverrides as Record<GridMode, ModalSizePreset> | undefined },
|
||||
currentMode,
|
||||
currentModeWidth,
|
||||
);
|
||||
|
||||
const handleDefaultChange = (preset: ModalSizePreset) => {
|
||||
onUpdate({
|
||||
sizeConfig: {
|
||||
...sizeConfig,
|
||||
default: preset,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTogglePerMode = () => {
|
||||
if (usePerMode) {
|
||||
onUpdate({
|
||||
sizeConfig: {
|
||||
default: sizeConfig.default,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
onUpdate({
|
||||
sizeConfig: {
|
||||
...sizeConfig,
|
||||
modeOverrides: {
|
||||
mobile_portrait: sizeConfig.default,
|
||||
mobile_landscape: sizeConfig.default,
|
||||
tablet_portrait: sizeConfig.default,
|
||||
tablet_landscape: sizeConfig.default,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleModeChange = (mode: GridMode, preset: ModalSizePreset) => {
|
||||
onUpdate({
|
||||
sizeConfig: {
|
||||
...sizeConfig,
|
||||
modeOverrides: {
|
||||
...sizeConfig.modeOverrides,
|
||||
[mode]: preset,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b bg-muted/20">
|
||||
{/* 헤더 (항상 표시) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex w-full items-center justify-between px-4 py-2 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded
|
||||
? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
: <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
}
|
||||
<span className="text-xs font-semibold">{modal.title}</span>
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||||
{sizeConfig.default.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{currentModalWidth}px / {currentModeWidth}px
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{modal.id}</span>
|
||||
</button>
|
||||
|
||||
{/* 펼침 영역 */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
{/* 기본 사이즈 선택 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] text-muted-foreground font-medium">모달 사이즈</span>
|
||||
<div className="flex gap-1">
|
||||
{SIZE_PRESET_ORDER.map(preset => {
|
||||
const info = MODAL_SIZE_PRESETS[preset];
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => handleDefaultChange(preset)}
|
||||
className={cn(
|
||||
"flex-1 h-8 rounded-md text-xs font-medium transition-colors flex flex-col items-center justify-center gap-0",
|
||||
sizeConfig.default === preset
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-background border hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<span className="leading-none">{preset.toUpperCase()}</span>
|
||||
<span className={cn(
|
||||
"text-[9px] leading-none",
|
||||
sizeConfig.default === preset ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||
)}>
|
||||
{preset === "full" ? "100%" : `${info.width}px`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모드별 개별 설정 토글 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-muted-foreground">모드별 개별 사이즈</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePerMode}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
usePerMode ? "bg-primary" : "bg-gray-300"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
usePerMode ? "translate-x-4.5" : "translate-x-0.5"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 모드별 설정 */}
|
||||
{usePerMode && (
|
||||
<div className="space-y-1.5">
|
||||
{MODE_LABELS.map(({ mode, label, icon: Icon }) => {
|
||||
const modePreset = sizeConfig.modeOverrides?.[mode] ?? sizeConfig.default;
|
||||
return (
|
||||
<div key={mode} className={cn(
|
||||
"flex items-center justify-between rounded-md px-2 py-1",
|
||||
mode === currentMode ? "bg-primary/10 ring-1 ring-primary/30" : ""
|
||||
)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px]">{label}</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
{SIZE_PRESET_ORDER.map(preset => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => handleModeChange(mode, preset)}
|
||||
className={cn(
|
||||
"h-6 px-1.5 rounded text-[10px] font-medium transition-colors",
|
||||
modePreset === preset
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-background border hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
{preset.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 캔버스 축소판 미리보기 */}
|
||||
<ModalThumbnailPreview sizeConfig={sizeConfig} currentMode={currentMode} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 모달 사이즈 썸네일 미리보기 (캔버스 축소판 + 모달 오버레이)
|
||||
// ========================================
|
||||
|
||||
function ModalThumbnailPreview({
|
||||
sizeConfig,
|
||||
currentMode,
|
||||
}: {
|
||||
sizeConfig: { default: ModalSizePreset; modeOverrides?: Partial<Record<GridMode, ModalSizePreset>> };
|
||||
currentMode: GridMode;
|
||||
}) {
|
||||
const PREVIEW_WIDTH = 260;
|
||||
const ASPECT_RATIO = 0.65;
|
||||
|
||||
const modeInfo = MODE_LABELS.find(m => m.mode === currentMode)!;
|
||||
const modeWidth = modeInfo.width;
|
||||
const modeHeight = modeWidth * ASPECT_RATIO;
|
||||
|
||||
const scale = PREVIEW_WIDTH / modeWidth;
|
||||
const previewHeight = Math.round(modeHeight * scale);
|
||||
|
||||
const modalWidth = resolveModalWidth(
|
||||
{ default: sizeConfig.default, modeOverrides: sizeConfig.modeOverrides as Record<GridMode, ModalSizePreset> | undefined },
|
||||
currentMode,
|
||||
modeWidth,
|
||||
);
|
||||
const scaledModalWidth = Math.min(Math.round(modalWidth * scale), PREVIEW_WIDTH);
|
||||
const isFull = modalWidth >= modeWidth;
|
||||
const scaledModalHeight = isFull ? previewHeight : Math.round(previewHeight * 0.75);
|
||||
const Icon = modeInfo.icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-muted-foreground font-medium">미리보기</span>
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Icon className="h-3 w-3" />
|
||||
<span>{modeInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative mx-auto rounded-md border bg-gray-100 overflow-hidden"
|
||||
style={{ width: `${PREVIEW_WIDTH}px`, height: `${previewHeight}px` }}
|
||||
>
|
||||
{/* 반투명 배경 오버레이 (모달이 열렸을 때의 딤 효과) */}
|
||||
<div className="absolute inset-0 bg-black/10" />
|
||||
|
||||
{/* 모달 영역 (가운데 정렬, FULL이면 전체 채움) */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute border-2 border-primary/60 bg-primary/15",
|
||||
isFull ? "rounded-none" : "rounded-sm"
|
||||
)}
|
||||
style={{
|
||||
width: `${scaledModalWidth}px`,
|
||||
height: `${scaledModalHeight}px`,
|
||||
left: `${(PREVIEW_WIDTH - scaledModalWidth) / 2}px`,
|
||||
top: `${(previewHeight - scaledModalHeight) / 2}px`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-1 left-1.5 text-[8px] font-medium text-primary/80 leading-none">
|
||||
모달
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 수치 표시 */}
|
||||
<div className="absolute bottom-1 right-1.5 rounded bg-black/50 px-1.5 py-0.5 text-[9px] text-white">
|
||||
{isFull ? "FULL" : `${modalWidth}px`} / {modeWidth}px
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,15 @@ import {
|
|||
createEmptyPopLayoutV5,
|
||||
isV5Layout,
|
||||
addComponentToV5Layout,
|
||||
createComponentDefinitionV5,
|
||||
GRID_BREAKPOINTS,
|
||||
PopModalDefinition,
|
||||
PopDataConnection,
|
||||
} from "./types/pop-layout";
|
||||
import { getAllEffectivePositions } from "./utils/gridUtils";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { PopDesignerContext } from "./PopDesignerContext";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
|
|
@ -51,6 +55,7 @@ export default function PopDesigner({
|
|||
onBackToList,
|
||||
onScreenUpdate,
|
||||
}: PopDesignerProps) {
|
||||
|
||||
// ========================================
|
||||
// 레이아웃 상태
|
||||
// ========================================
|
||||
|
|
@ -69,13 +74,24 @@ export default function PopDesigner({
|
|||
// 선택 상태
|
||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||
|
||||
// 대시보드 페이지 미리보기 인덱스 (-1 = 기본 모드)
|
||||
const [previewPageIndex, setPreviewPageIndex] = useState<number>(-1);
|
||||
|
||||
// 그리드 모드 (4개 프리셋)
|
||||
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
||||
|
||||
// 선택된 컴포넌트
|
||||
const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId
|
||||
? layout.components[selectedComponentId] || null
|
||||
: null;
|
||||
// 모달 캔버스 활성 상태 ("main" 또는 모달 ID)
|
||||
const [activeCanvasId, setActiveCanvasId] = useState<string>("main");
|
||||
|
||||
// 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회)
|
||||
const selectedComponent: PopComponentDefinitionV5 | null = (() => {
|
||||
if (!selectedComponentId) return null;
|
||||
if (activeCanvasId === "main") {
|
||||
return layout.components[selectedComponentId] || null;
|
||||
}
|
||||
const modal = layout.modals?.find(m => m.id === activeCanvasId);
|
||||
return modal?.components[selectedComponentId] || null;
|
||||
})();
|
||||
|
||||
// ========================================
|
||||
// 히스토리 관리
|
||||
|
|
@ -206,52 +222,169 @@ export default function PopDesigner({
|
|||
(type: PopComponentType, position: PopGridPosition) => {
|
||||
const componentId = `comp_${idCounter}`;
|
||||
setIdCounter((prev) => prev + 1);
|
||||
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
|
||||
if (activeCanvasId === "main") {
|
||||
// 메인 캔버스
|
||||
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
} else {
|
||||
// 모달 캔버스
|
||||
setLayout(prev => {
|
||||
const comp = createComponentDefinitionV5(componentId, type, position, `${type} ${idCounter}`);
|
||||
const newLayout = {
|
||||
...prev,
|
||||
modals: (prev.modals || []).map(m => {
|
||||
if (m.id !== activeCanvasId) return m;
|
||||
return { ...m, components: { ...m.components, [componentId]: comp } };
|
||||
}),
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
});
|
||||
}
|
||||
setSelectedComponentId(componentId);
|
||||
setHasChanges(true);
|
||||
},
|
||||
[idCounter, layout, saveToHistory]
|
||||
[idCounter, layout, saveToHistory, activeCanvasId]
|
||||
);
|
||||
|
||||
const handleUpdateComponent = useCallback(
|
||||
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
|
||||
const existingComponent = layout.components[componentId];
|
||||
if (!existingComponent) return;
|
||||
// 함수적 업데이트로 stale closure 방지
|
||||
setLayout((prev) => {
|
||||
if (activeCanvasId === "main") {
|
||||
// 메인 캔버스
|
||||
const existingComponent = prev.components[componentId];
|
||||
if (!existingComponent) return prev;
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: {
|
||||
...layout.components,
|
||||
[componentId]: {
|
||||
...existingComponent,
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
const newLayout = {
|
||||
...prev,
|
||||
components: {
|
||||
...prev.components,
|
||||
[componentId]: { ...existingComponent, ...updates },
|
||||
},
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
} else {
|
||||
// 모달 캔버스
|
||||
const newLayout = {
|
||||
...prev,
|
||||
modals: (prev.modals || []).map(m => {
|
||||
if (m.id !== activeCanvasId) return m;
|
||||
const existing = m.components[componentId];
|
||||
if (!existing) return m;
|
||||
return {
|
||||
...m,
|
||||
components: {
|
||||
...m.components,
|
||||
[componentId]: { ...existing, ...updates },
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
}
|
||||
});
|
||||
setHasChanges(true);
|
||||
},
|
||||
[layout, saveToHistory]
|
||||
[saveToHistory, activeCanvasId]
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 연결 CRUD
|
||||
// ========================================
|
||||
|
||||
const handleAddConnection = useCallback(
|
||||
(conn: Omit<PopDataConnection, "id">) => {
|
||||
setLayout((prev) => {
|
||||
const newId = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const newConnection: PopDataConnection = { ...conn, id: newId };
|
||||
const prevConnections = prev.dataFlow?.connections || [];
|
||||
const newLayout: PopLayoutDataV5 = {
|
||||
...prev,
|
||||
dataFlow: {
|
||||
...prev.dataFlow,
|
||||
connections: [...prevConnections, newConnection],
|
||||
},
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
});
|
||||
setHasChanges(true);
|
||||
},
|
||||
[saveToHistory]
|
||||
);
|
||||
|
||||
const handleUpdateConnection = useCallback(
|
||||
(connectionId: string, conn: Omit<PopDataConnection, "id">) => {
|
||||
setLayout((prev) => {
|
||||
const prevConnections = prev.dataFlow?.connections || [];
|
||||
const newLayout: PopLayoutDataV5 = {
|
||||
...prev,
|
||||
dataFlow: {
|
||||
...prev.dataFlow,
|
||||
connections: prevConnections.map((c) =>
|
||||
c.id === connectionId ? { ...conn, id: connectionId } : c
|
||||
),
|
||||
},
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
});
|
||||
setHasChanges(true);
|
||||
},
|
||||
[saveToHistory]
|
||||
);
|
||||
|
||||
const handleRemoveConnection = useCallback(
|
||||
(connectionId: string) => {
|
||||
setLayout((prev) => {
|
||||
const prevConnections = prev.dataFlow?.connections || [];
|
||||
const newLayout: PopLayoutDataV5 = {
|
||||
...prev,
|
||||
dataFlow: {
|
||||
...prev.dataFlow,
|
||||
connections: prevConnections.filter((c) => c.id !== connectionId),
|
||||
},
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
});
|
||||
setHasChanges(true);
|
||||
},
|
||||
[saveToHistory]
|
||||
);
|
||||
|
||||
const handleDeleteComponent = useCallback(
|
||||
(componentId: string) => {
|
||||
const newComponents = { ...layout.components };
|
||||
delete newComponents[componentId];
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: newComponents,
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setLayout(prev => {
|
||||
if (activeCanvasId === "main") {
|
||||
const newComponents = { ...prev.components };
|
||||
delete newComponents[componentId];
|
||||
const newLayout = { ...prev, components: newComponents };
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
} else {
|
||||
const newLayout = {
|
||||
...prev,
|
||||
modals: (prev.modals || []).map(m => {
|
||||
if (m.id !== activeCanvasId) return m;
|
||||
const newComps = { ...m.components };
|
||||
delete newComps[componentId];
|
||||
return { ...m, components: newComps };
|
||||
}),
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
}
|
||||
});
|
||||
setSelectedComponentId(null);
|
||||
setHasChanges(true);
|
||||
},
|
||||
[layout, saveToHistory]
|
||||
[saveToHistory, activeCanvasId]
|
||||
);
|
||||
|
||||
const handleMoveComponent = useCallback(
|
||||
|
|
@ -357,6 +490,56 @@ export default function PopDesigner({
|
|||
[layout, saveToHistory]
|
||||
);
|
||||
|
||||
// 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등)
|
||||
const handleRequestResize = useCallback(
|
||||
(componentId: string, newRowSpan: number, newColSpan?: number) => {
|
||||
const component = layout.components[componentId];
|
||||
if (!component) return;
|
||||
|
||||
const newPosition = {
|
||||
...component.position,
|
||||
rowSpan: newRowSpan,
|
||||
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
|
||||
};
|
||||
|
||||
// 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: {
|
||||
...layout.components,
|
||||
[componentId]: {
|
||||
...component,
|
||||
position: newPosition,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
} else {
|
||||
// 다른 모드인 경우: 오버라이드에 저장
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: {
|
||||
...layout.overrides,
|
||||
[currentMode]: {
|
||||
...layout.overrides?.[currentMode],
|
||||
positions: {
|
||||
...layout.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
}
|
||||
},
|
||||
[layout, currentMode, saveToHistory]
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// Gap 프리셋 관리
|
||||
// ========================================
|
||||
|
|
@ -471,6 +654,59 @@ export default function PopDesigner({
|
|||
setHasChanges(true);
|
||||
}, [layout, currentMode, saveToHistory]);
|
||||
|
||||
// ========================================
|
||||
// 모달 캔버스 관리
|
||||
// ========================================
|
||||
|
||||
/** 모달 ID 자동 생성 (계층적: modal-1, modal-1-1, modal-1-1-1) */
|
||||
const generateModalId = useCallback((parentCanvasId: string): string => {
|
||||
const modals = layout.modals || [];
|
||||
if (parentCanvasId === "main") {
|
||||
const rootModals = modals.filter(m => !m.parentId);
|
||||
return `modal-${rootModals.length + 1}`;
|
||||
}
|
||||
const prefix = parentCanvasId.replace("modal-", "");
|
||||
const children = modals.filter(m => m.parentId === parentCanvasId);
|
||||
return `modal-${prefix}-${children.length + 1}`;
|
||||
}, [layout.modals]);
|
||||
|
||||
/** 모달 캔버스 생성하고 해당 탭으로 전환 */
|
||||
const createModalCanvas = useCallback((buttonComponentId: string, title: string): string => {
|
||||
const modalId = generateModalId(activeCanvasId);
|
||||
const newModal: PopModalDefinition = {
|
||||
id: modalId,
|
||||
parentId: activeCanvasId === "main" ? undefined : activeCanvasId,
|
||||
title: title || "새 모달",
|
||||
sourceButtonId: buttonComponentId,
|
||||
gridConfig: { ...layout.gridConfig },
|
||||
components: {},
|
||||
};
|
||||
setLayout(prev => ({
|
||||
...prev,
|
||||
modals: [...(prev.modals || []), newModal],
|
||||
}));
|
||||
setHasChanges(true);
|
||||
setActiveCanvasId(modalId);
|
||||
return modalId;
|
||||
}, [generateModalId, activeCanvasId, layout.gridConfig]);
|
||||
|
||||
/** 모달 정의 업데이트 (제목, sizeConfig 등) */
|
||||
const handleUpdateModal = useCallback((modalId: string, updates: Partial<PopModalDefinition>) => {
|
||||
setLayout(prev => ({
|
||||
...prev,
|
||||
modals: (prev.modals || []).map(m =>
|
||||
m.id === modalId ? { ...m, ...updates } : m
|
||||
),
|
||||
}));
|
||||
setHasChanges(true);
|
||||
}, []);
|
||||
|
||||
/** 특정 캔버스로 전환 */
|
||||
const navigateToCanvas = useCallback((canvasId: string) => {
|
||||
setActiveCanvasId(canvasId);
|
||||
setSelectedComponentId(null);
|
||||
}, []);
|
||||
|
||||
// ========================================
|
||||
// 뒤로가기
|
||||
// ========================================
|
||||
|
|
@ -553,6 +789,14 @@ export default function PopDesigner({
|
|||
// 렌더링
|
||||
// ========================================
|
||||
return (
|
||||
<PopDesignerContext.Provider
|
||||
value={{
|
||||
createModalCanvas,
|
||||
navigateToCanvas,
|
||||
activeCanvasId,
|
||||
selectedComponentId,
|
||||
}}
|
||||
>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* 헤더 */}
|
||||
|
|
@ -637,6 +881,11 @@ export default function PopDesigner({
|
|||
onLockLayout={handleLockLayout}
|
||||
onResetOverride={handleResetOverride}
|
||||
onChangeGapPreset={handleChangeGapPreset}
|
||||
onRequestResize={handleRequestResize}
|
||||
previewPageIndex={previewPageIndex}
|
||||
activeCanvasId={activeCanvasId}
|
||||
onActiveCanvasChange={navigateToCanvas}
|
||||
onUpdateModal={handleUpdateModal}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
|
|
@ -652,10 +901,21 @@ export default function PopDesigner({
|
|||
? (updates) => handleUpdateComponent(selectedComponentId, updates)
|
||||
: undefined
|
||||
}
|
||||
allComponents={Object.values(layout.components)}
|
||||
onSelectComponent={setSelectedComponentId}
|
||||
selectedComponentId={selectedComponentId}
|
||||
previewPageIndex={previewPageIndex}
|
||||
onPreviewPage={setPreviewPageIndex}
|
||||
connections={layout.dataFlow?.connections || []}
|
||||
onAddConnection={handleAddConnection}
|
||||
onUpdateConnection={handleUpdateConnection}
|
||||
onRemoveConnection={handleRemoveConnection}
|
||||
modals={layout.modals}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</DndProvider>
|
||||
</PopDesignerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* PopDesignerContext - 디자이너 전역 컨텍스트
|
||||
*
|
||||
* ConfigPanel 등 하위 컴포넌트에서 디자이너 레벨 동작을 트리거하기 위한 컨텍스트.
|
||||
* 예: pop-button 설정 패널에서 "모달 캔버스 생성" 버튼 클릭 시
|
||||
* 디자이너의 activeCanvasId를 변경하고 새 모달을 생성.
|
||||
*
|
||||
* Provider: PopDesigner.tsx
|
||||
* Consumer: pop-button ConfigPanel (ModalCanvasButton)
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export interface PopDesignerContextType {
|
||||
/** 새 모달 캔버스 생성하고 해당 탭으로 전환 (모달 ID 반환) */
|
||||
createModalCanvas: (buttonComponentId: string, title: string) => string;
|
||||
/** 특정 캔버스(메인 또는 모달)로 전환 */
|
||||
navigateToCanvas: (canvasId: string) => void;
|
||||
/** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */
|
||||
activeCanvasId: string;
|
||||
/** 현재 선택된 컴포넌트 ID */
|
||||
selectedComponentId: string | null;
|
||||
}
|
||||
|
||||
export const PopDesignerContext = createContext<PopDesignerContextType | null>(null);
|
||||
|
||||
/**
|
||||
* 디자이너 컨텍스트 사용 훅
|
||||
* 뷰어 모드에서는 null 반환 (Provider 없음)
|
||||
*/
|
||||
export function usePopDesignerContext(): PopDesignerContextType | null {
|
||||
return useContext(PopDesignerContext);
|
||||
}
|
||||
|
|
@ -7,21 +7,23 @@ import {
|
|||
PopGridPosition,
|
||||
GridMode,
|
||||
GRID_BREAKPOINTS,
|
||||
PopComponentType,
|
||||
} from "../types/pop-layout";
|
||||
import {
|
||||
Settings,
|
||||
Database,
|
||||
Link2,
|
||||
Eye,
|
||||
Grid3x3,
|
||||
MoveHorizontal,
|
||||
MoveVertical,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||
import { PopDataConnection, PopModalDefinition } from "../types/pop-layout";
|
||||
import ConnectionEditor from "./ConnectionEditor";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
|
|
@ -36,14 +38,41 @@ interface ComponentEditorPanelProps {
|
|||
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
/** 그리드에 배치된 모든 컴포넌트 */
|
||||
allComponents?: PopComponentDefinitionV5[];
|
||||
/** 컴포넌트 선택 콜백 */
|
||||
onSelectComponent?: (componentId: string) => void;
|
||||
/** 현재 선택된 컴포넌트 ID */
|
||||
selectedComponentId?: string | null;
|
||||
/** 대시보드 페이지 미리보기 인덱스 */
|
||||
previewPageIndex?: number;
|
||||
/** 페이지 미리보기 요청 콜백 */
|
||||
onPreviewPage?: (pageIndex: number) => void;
|
||||
/** 데이터 흐름 연결 목록 */
|
||||
connections?: PopDataConnection[];
|
||||
/** 연결 추가 콜백 */
|
||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||
/** 연결 수정 콜백 */
|
||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||
/** 연결 삭제 콜백 */
|
||||
onRemoveConnection?: (connectionId: string) => void;
|
||||
/** 모달 정의 목록 (설정 패널에 전달) */
|
||||
modals?: PopModalDefinition[];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 타입별 라벨
|
||||
// ========================================
|
||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||
const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
||||
"pop-sample": "샘플",
|
||||
"pop-text": "텍스트",
|
||||
"pop-icon": "아이콘",
|
||||
"pop-dashboard": "대시보드",
|
||||
"pop-card-list": "카드 목록",
|
||||
"pop-field": "필드",
|
||||
"pop-button": "버튼",
|
||||
"pop-string-list": "리스트 목록",
|
||||
"pop-search": "검색",
|
||||
"pop-list": "리스트",
|
||||
"pop-indicator": "인디케이터",
|
||||
"pop-scanner": "스캐너",
|
||||
|
|
@ -61,6 +90,16 @@ export default function ComponentEditorPanel({
|
|||
currentMode,
|
||||
onUpdateComponent,
|
||||
className,
|
||||
allComponents,
|
||||
onSelectComponent,
|
||||
selectedComponentId,
|
||||
previewPageIndex,
|
||||
onPreviewPage,
|
||||
connections,
|
||||
onAddConnection,
|
||||
onUpdateConnection,
|
||||
onRemoveConnection,
|
||||
modals,
|
||||
}: ComponentEditorPanelProps) {
|
||||
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||||
|
||||
|
|
@ -97,8 +136,8 @@ export default function ComponentEditorPanel({
|
|||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<Tabs defaultValue="position" className="flex flex-1 flex-col">
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
||||
<Tabs defaultValue="position" className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2 flex-shrink-0">
|
||||
<TabsTrigger value="position" className="gap-1 text-xs">
|
||||
<Grid3x3 className="h-3 w-3" />
|
||||
위치
|
||||
|
|
@ -111,14 +150,51 @@ export default function ComponentEditorPanel({
|
|||
<Eye className="h-3 w-3" />
|
||||
표시
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="data" className="gap-1 text-xs">
|
||||
<Database className="h-3 w-3" />
|
||||
데이터
|
||||
<TabsTrigger value="connection" className="gap-1 text-xs">
|
||||
<Link2 className="h-3 w-3" />
|
||||
연결
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 위치 탭 */}
|
||||
<TabsContent value="position" className="flex-1 overflow-auto p-4">
|
||||
<TabsContent value="position" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||
{/* 배치된 컴포넌트 목록 */}
|
||||
{allComponents && allComponents.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<Layers className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
배치된 컴포넌트 ({allComponents.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{allComponents.map((comp) => {
|
||||
const label = comp.label
|
||||
|| COMPONENT_TYPE_LABELS[comp.type]
|
||||
|| comp.type;
|
||||
const isActive = comp.id === selectedComponentId;
|
||||
return (
|
||||
<button
|
||||
key={comp.id}
|
||||
onClick={() => onSelectComponent?.(comp.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-gray-100 text-gray-600"
|
||||
)}
|
||||
>
|
||||
<span className="truncate flex-1">{label}</span>
|
||||
<span className="shrink-0 text-[10px] text-gray-400">
|
||||
({comp.position.col},{comp.position.row})
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="h-px bg-gray-200 mt-3" />
|
||||
</div>
|
||||
)}
|
||||
<PositionForm
|
||||
component={component}
|
||||
currentMode={currentMode}
|
||||
|
|
@ -129,24 +205,35 @@ export default function ComponentEditorPanel({
|
|||
</TabsContent>
|
||||
|
||||
{/* 설정 탭 */}
|
||||
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
||||
<TabsContent value="settings" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||
<ComponentSettingsForm
|
||||
component={component}
|
||||
onUpdate={onUpdateComponent}
|
||||
currentMode={currentMode}
|
||||
previewPageIndex={previewPageIndex}
|
||||
onPreviewPage={onPreviewPage}
|
||||
modals={modals}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 표시 탭 */}
|
||||
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
|
||||
<TabsContent value="visibility" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||
<VisibilityForm
|
||||
component={component}
|
||||
onUpdate={onUpdateComponent}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 데이터 탭 */}
|
||||
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
||||
<DataBindingPlaceholder />
|
||||
{/* 연결 탭 */}
|
||||
<TabsContent value="connection" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
|
||||
<ConnectionEditor
|
||||
component={component}
|
||||
allComponents={allComponents || []}
|
||||
connections={connections || []}
|
||||
onAddConnection={onAddConnection}
|
||||
onUpdateConnection={onUpdateConnection}
|
||||
onRemoveConnection={onRemoveConnection}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
@ -313,9 +400,13 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
|
|||
interface ComponentSettingsFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
currentMode?: GridMode;
|
||||
previewPageIndex?: number;
|
||||
onPreviewPage?: (pageIndex: number) => void;
|
||||
modals?: PopModalDefinition[];
|
||||
}
|
||||
|
||||
function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) {
|
||||
function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) {
|
||||
// PopComponentRegistry에서 configPanel 가져오기
|
||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||
const ConfigPanel = registeredComp?.configPanel;
|
||||
|
|
@ -344,6 +435,11 @@ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormPro
|
|||
<ConfigPanel
|
||||
config={component.config || {}}
|
||||
onUpdate={handleConfigUpdate}
|
||||
currentMode={currentMode}
|
||||
currentColSpan={component.position.colSpan}
|
||||
onPreviewPage={onPreviewPage}
|
||||
previewPageIndex={previewPageIndex}
|
||||
modals={modals}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
|
|
@ -419,20 +515,3 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
|||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 데이터 바인딩 플레이스홀더
|
||||
// ========================================
|
||||
|
||||
function DataBindingPlaceholder() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-gray-50 p-4 text-center">
|
||||
<Database className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-gray-700">데이터 바인딩</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Phase 4에서 구현 예정
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useDrag } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PopComponentType } from "../types/pop-layout";
|
||||
import { Square, FileText } from "lucide-react";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search } from "lucide-react";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
|
||||
// 컴포넌트 정의
|
||||
|
|
@ -27,6 +27,42 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
icon: FileText,
|
||||
description: "텍스트, 시간, 이미지 표시",
|
||||
},
|
||||
{
|
||||
type: "pop-icon",
|
||||
label: "아이콘",
|
||||
icon: MousePointer,
|
||||
description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)",
|
||||
},
|
||||
{
|
||||
type: "pop-dashboard",
|
||||
label: "대시보드",
|
||||
icon: BarChart3,
|
||||
description: "KPI, 차트, 게이지, 통계 집계",
|
||||
},
|
||||
{
|
||||
type: "pop-card-list",
|
||||
label: "카드 목록",
|
||||
icon: LayoutGrid,
|
||||
description: "테이블 데이터를 카드 형태로 표시",
|
||||
},
|
||||
{
|
||||
type: "pop-button",
|
||||
label: "버튼",
|
||||
icon: MousePointerClick,
|
||||
description: "액션 버튼 (저장/삭제/API/모달)",
|
||||
},
|
||||
{
|
||||
type: "pop-string-list",
|
||||
label: "리스트 목록",
|
||||
icon: List,
|
||||
description: "테이블 데이터를 리스트/카드로 표시",
|
||||
},
|
||||
{
|
||||
type: "pop-search",
|
||||
label: "검색",
|
||||
icon: Search,
|
||||
description: "조건 입력 (텍스트/날짜/선택/모달)",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
|
|
|||
|
|
@ -0,0 +1,623 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
PopComponentDefinitionV5,
|
||||
PopDataConnection,
|
||||
} from "../types/pop-layout";
|
||||
import {
|
||||
PopComponentRegistry,
|
||||
type ComponentConnectionMeta,
|
||||
} from "@/lib/registry/PopComponentRegistry";
|
||||
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
// ========================================
|
||||
|
||||
interface ConnectionEditorProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
connections: PopDataConnection[];
|
||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||
onRemoveConnection?: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ConnectionEditor
|
||||
// ========================================
|
||||
|
||||
export default function ConnectionEditor({
|
||||
component,
|
||||
allComponents,
|
||||
connections,
|
||||
onAddConnection,
|
||||
onUpdateConnection,
|
||||
onRemoveConnection,
|
||||
}: ConnectionEditorProps) {
|
||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||
const meta = registeredComp?.connectionMeta;
|
||||
|
||||
const outgoing = connections.filter(
|
||||
(c) => c.sourceComponent === component.id
|
||||
);
|
||||
const incoming = connections.filter(
|
||||
(c) => c.targetComponent === component.id
|
||||
);
|
||||
|
||||
const hasSendable = meta?.sendable && meta.sendable.length > 0;
|
||||
const hasReceivable = meta?.receivable && meta.receivable.length > 0;
|
||||
|
||||
if (!hasSendable && !hasReceivable) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-gray-50 p-4 text-center">
|
||||
<Link2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-gray-700">연결 없음</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
이 컴포넌트는 다른 컴포넌트와 연결할 수 없습니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{hasSendable && (
|
||||
<SendSection
|
||||
component={component}
|
||||
meta={meta!}
|
||||
allComponents={allComponents}
|
||||
outgoing={outgoing}
|
||||
onAddConnection={onAddConnection}
|
||||
onUpdateConnection={onUpdateConnection}
|
||||
onRemoveConnection={onRemoveConnection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasReceivable && (
|
||||
<ReceiveSection
|
||||
component={component}
|
||||
meta={meta!}
|
||||
allComponents={allComponents}
|
||||
incoming={incoming}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 대상 컴포넌트에서 정보 추출
|
||||
// ========================================
|
||||
|
||||
/** 화면에 표시 중인 컬럼만 추출 */
|
||||
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
||||
if (!comp?.config) return [];
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
const cols: string[] = [];
|
||||
|
||||
if (Array.isArray(cfg.listColumns)) {
|
||||
(cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => {
|
||||
if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName);
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(cfg.selectedColumns)) {
|
||||
(cfg.selectedColumns as string[]).forEach((c) => {
|
||||
if (!cols.includes(c)) cols.push(c);
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
}
|
||||
|
||||
/** 대상 컴포넌트의 데이터소스 테이블명 추출 */
|
||||
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
|
||||
if (!comp?.config) return "";
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
const ds = cfg.dataSource as { tableName?: string } | undefined;
|
||||
return ds?.tableName || "";
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 보내기 섹션
|
||||
// ========================================
|
||||
|
||||
interface SendSectionProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
outgoing: PopDataConnection[];
|
||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||
onRemoveConnection?: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
function SendSection({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
outgoing,
|
||||
onAddConnection,
|
||||
onUpdateConnection,
|
||||
onRemoveConnection,
|
||||
}: SendSectionProps) {
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||
<ArrowRight className="h-3 w-3 text-blue-500" />
|
||||
이때 (보내기)
|
||||
</Label>
|
||||
|
||||
{/* 기존 연결 목록 */}
|
||||
{outgoing.map((conn) => (
|
||||
<div key={conn.id}>
|
||||
{editingId === conn.id ? (
|
||||
<ConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2">
|
||||
<span className="flex-1 truncate text-xs">
|
||||
{conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditingId(conn.id)}
|
||||
className="shrink-0 p-0.5 text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
{onRemoveConnection && (
|
||||
<button
|
||||
onClick={() => onRemoveConnection(conn.id)}
|
||||
className="shrink-0 p-0.5 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 새 연결 추가 */}
|
||||
<ConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 연결 폼 (추가/수정 공용)
|
||||
// ========================================
|
||||
|
||||
interface ConnectionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
initial?: PopDataConnection;
|
||||
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
function ConnectionForm({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: ConnectionFormProps) {
|
||||
const [selectedOutput, setSelectedOutput] = React.useState(
|
||||
initial?.sourceOutput || meta.sendable[0]?.key || ""
|
||||
);
|
||||
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
||||
initial?.targetComponent || ""
|
||||
);
|
||||
const [selectedTargetInput, setSelectedTargetInput] = React.useState(
|
||||
initial?.targetInput || ""
|
||||
);
|
||||
const [filterColumns, setFilterColumns] = React.useState<string[]>(
|
||||
initial?.filterConfig?.targetColumns ||
|
||||
(initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : [])
|
||||
);
|
||||
const [filterMode, setFilterMode] = React.useState<
|
||||
"equals" | "contains" | "starts_with" | "range"
|
||||
>(initial?.filterConfig?.filterMode || "contains");
|
||||
|
||||
const targetCandidates = allComponents.filter((c) => {
|
||||
if (c.id === component.id) return false;
|
||||
const reg = PopComponentRegistry.getComponent(c.type);
|
||||
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
||||
});
|
||||
|
||||
const targetComp = selectedTargetId
|
||||
? allComponents.find((c) => c.id === selectedTargetId)
|
||||
: null;
|
||||
|
||||
const targetMeta = targetComp
|
||||
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
||||
: null;
|
||||
|
||||
// 화면에 표시 중인 컬럼
|
||||
const displayColumns = React.useMemo(
|
||||
() => extractDisplayColumns(targetComp || undefined),
|
||||
[targetComp]
|
||||
);
|
||||
|
||||
// DB 테이블 전체 컬럼 (비동기 조회)
|
||||
const tableName = React.useMemo(
|
||||
() => extractTableName(targetComp || undefined),
|
||||
[targetComp]
|
||||
);
|
||||
const [allDbColumns, setAllDbColumns] = React.useState<string[]>([]);
|
||||
const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!tableName) {
|
||||
setAllDbColumns([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setDbColumnsLoading(true);
|
||||
getTableColumns(tableName).then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data?.columns) {
|
||||
setAllDbColumns(res.data.columns.map((c) => c.columnName));
|
||||
} else {
|
||||
setAllDbColumns([]);
|
||||
}
|
||||
setDbColumnsLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [tableName]);
|
||||
|
||||
// 표시 컬럼과 데이터 전용 컬럼 분리
|
||||
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
|
||||
const dataOnlyColumns = React.useMemo(
|
||||
() => allDbColumns.filter((c) => !displaySet.has(c)),
|
||||
[allDbColumns, displaySet]
|
||||
);
|
||||
const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0;
|
||||
|
||||
const toggleColumn = (col: string) => {
|
||||
setFilterColumns((prev) =>
|
||||
prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
|
||||
|
||||
onSubmit({
|
||||
sourceComponent: component.id,
|
||||
sourceField: "",
|
||||
sourceOutput: selectedOutput,
|
||||
targetComponent: selectedTargetId,
|
||||
targetField: "",
|
||||
targetInput: selectedTargetInput,
|
||||
filterConfig:
|
||||
filterColumns.length > 0
|
||||
? {
|
||||
targetColumn: filterColumns[0],
|
||||
targetColumns: filterColumns,
|
||||
filterMode,
|
||||
}
|
||||
: undefined,
|
||||
label: buildConnectionLabel(
|
||||
component,
|
||||
selectedOutput,
|
||||
allComponents.find((c) => c.id === selectedTargetId),
|
||||
selectedTargetInput,
|
||||
filterColumns
|
||||
),
|
||||
});
|
||||
|
||||
if (!initial) {
|
||||
setSelectedTargetId("");
|
||||
setSelectedTargetInput("");
|
||||
setFilterColumns([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded border border-dashed p-3">
|
||||
{onCancel && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">연결 수정</p>
|
||||
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!onCancel && (
|
||||
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
||||
)}
|
||||
|
||||
{/* 보내는 값 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">보내는 값</span>
|
||||
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{meta.sendable.map((s) => (
|
||||
<SelectItem key={s.key} value={s.key} className="text-xs">
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 받는 컴포넌트 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">받는 컴포넌트</span>
|
||||
<Select
|
||||
value={selectedTargetId}
|
||||
onValueChange={(v) => {
|
||||
setSelectedTargetId(v);
|
||||
setSelectedTargetInput("");
|
||||
setFilterColumns([]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetCandidates.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||
{c.label || c.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 받는 방식 */}
|
||||
{targetMeta && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">받는 방식</span>
|
||||
<Select value={selectedTargetInput} onValueChange={setSelectedTargetInput}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetMeta.receivable.map((r) => (
|
||||
<SelectItem key={r.key} value={r.key} className="text-xs">
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 설정 */}
|
||||
{selectedTargetInput && (
|
||||
<div className="space-y-2 rounded bg-gray-50 p-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
||||
|
||||
{dbColumnsLoading ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-[10px] text-muted-foreground">컬럼 조회 중...</span>
|
||||
</div>
|
||||
) : hasAnyColumns ? (
|
||||
<div className="space-y-2">
|
||||
{/* 표시 컬럼 그룹 */}
|
||||
{displayColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-medium text-green-600">화면 표시 컬럼</p>
|
||||
{displayColumns.map((col) => (
|
||||
<div key={col} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`col-${col}-${initial?.id || "new"}`}
|
||||
checked={filterColumns.includes(col)}
|
||||
onCheckedChange={() => toggleColumn(col)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`col-${col}-${initial?.id || "new"}`}
|
||||
className="cursor-pointer text-xs"
|
||||
>
|
||||
{col}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 전용 컬럼 그룹 */}
|
||||
{dataOnlyColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{displayColumns.length > 0 && (
|
||||
<div className="my-1 h-px bg-gray-200" />
|
||||
)}
|
||||
<p className="text-[9px] font-medium text-amber-600">데이터 전용 컬럼</p>
|
||||
{dataOnlyColumns.map((col) => (
|
||||
<div key={col} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`col-${col}-${initial?.id || "new"}`}
|
||||
checked={filterColumns.includes(col)}
|
||||
onCheckedChange={() => toggleColumn(col)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`col-${col}-${initial?.id || "new"}`}
|
||||
className="cursor-pointer text-xs text-muted-foreground"
|
||||
>
|
||||
{col}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={filterColumns[0] || ""}
|
||||
onChange={(e) => setFilterColumns(e.target.value ? [e.target.value] : [])}
|
||||
placeholder="컬럼명 입력"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{filterColumns.length > 0 && (
|
||||
<p className="text-[10px] text-blue-600">
|
||||
{filterColumns.length}개 컬럼 중 하나라도 일치하면 표시
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 필터 방식 */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-muted-foreground">필터 방식</p>
|
||||
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="contains" className="text-xs">포함</SelectItem>
|
||||
<SelectItem value="equals" className="text-xs">일치</SelectItem>
|
||||
<SelectItem value="starts_with" className="text-xs">시작</SelectItem>
|
||||
<SelectItem value="range" className="text-xs">범위</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제출 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 받기 섹션 (읽기 전용)
|
||||
// ========================================
|
||||
|
||||
interface ReceiveSectionProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
incoming: PopDataConnection[];
|
||||
}
|
||||
|
||||
function ReceiveSection({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
incoming,
|
||||
}: ReceiveSectionProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||
<Unlink2 className="h-3 w-3 text-green-500" />
|
||||
이렇게 (받기)
|
||||
</Label>
|
||||
|
||||
<div className="space-y-1">
|
||||
{meta.receivable.map((r) => (
|
||||
<div
|
||||
key={r.key}
|
||||
className="rounded bg-green-50/50 px-3 py-2 text-xs text-gray-600"
|
||||
>
|
||||
<span className="font-medium">{r.label}</span>
|
||||
{r.description && (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{r.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{incoming.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] text-muted-foreground">연결된 소스</p>
|
||||
{incoming.map((conn) => {
|
||||
const sourceComp = allComponents.find(
|
||||
(c) => c.id === conn.sourceComponent
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="flex items-center gap-2 rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||
>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{sourceComp?.label || conn.sourceComponent}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 유틸
|
||||
// ========================================
|
||||
|
||||
function buildConnectionLabel(
|
||||
source: PopComponentDefinitionV5,
|
||||
_outputKey: string,
|
||||
target: PopComponentDefinitionV5 | undefined,
|
||||
_inputKey: string,
|
||||
columns?: string[]
|
||||
): string {
|
||||
const srcLabel = source.label || source.id;
|
||||
const tgtLabel = target?.label || target?.id || "?";
|
||||
const colInfo = columns && columns.length > 0
|
||||
? ` [${columns.join(", ")}]`
|
||||
: "";
|
||||
return `${srcLabel} -> ${tgtLabel}${colInfo}`;
|
||||
}
|
||||
|
|
@ -48,12 +48,18 @@ interface PopRendererProps {
|
|||
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||
/** 컴포넌트 크기 조정 완료 (히스토리 저장용) */
|
||||
onComponentResizeEnd?: (componentId: string) => void;
|
||||
/** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */
|
||||
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||
/** Gap 오버라이드 (Gap 프리셋 적용된 값) */
|
||||
overrideGap?: number;
|
||||
/** Padding 오버라이드 (Gap 프리셋 적용된 값) */
|
||||
overridePadding?: number;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
/** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */
|
||||
currentScreenId?: number;
|
||||
/** 대시보드 페이지 미리보기 인덱스 */
|
||||
previewPageIndex?: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
@ -62,6 +68,13 @@ interface PopRendererProps {
|
|||
|
||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||
"pop-sample": "샘플",
|
||||
"pop-text": "텍스트",
|
||||
"pop-icon": "아이콘",
|
||||
"pop-dashboard": "대시보드",
|
||||
"pop-card-list": "카드 목록",
|
||||
"pop-button": "버튼",
|
||||
"pop-string-list": "리스트 목록",
|
||||
"pop-search": "검색",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
|
@ -80,9 +93,12 @@ export default function PopRenderer({
|
|||
onComponentMove,
|
||||
onComponentResize,
|
||||
onComponentResizeEnd,
|
||||
onRequestResize,
|
||||
overrideGap,
|
||||
overridePadding,
|
||||
className,
|
||||
currentScreenId,
|
||||
previewPageIndex,
|
||||
}: PopRendererProps) {
|
||||
const { gridConfig, components, overrides } = layout;
|
||||
|
||||
|
|
@ -110,18 +126,27 @@ export default function PopRenderer({
|
|||
return Math.max(10, maxRowEnd + 3);
|
||||
}, [components, overrides, mode, hiddenIds]);
|
||||
|
||||
// CSS Grid 스타일 (행 높이 강제 고정: 셀 크기 = 컴포넌트 크기의 기준)
|
||||
// CSS Grid 스타일
|
||||
// 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집)
|
||||
// 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능)
|
||||
const rowTemplate = isDesignMode
|
||||
? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`
|
||||
: `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`;
|
||||
const autoRowHeight = isDesignMode
|
||||
? `${breakpoint.rowHeight}px`
|
||||
: `minmax(${breakpoint.rowHeight}px, auto)`;
|
||||
|
||||
const gridStyle = useMemo((): React.CSSProperties => ({
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`,
|
||||
gridAutoRows: `${breakpoint.rowHeight}px`,
|
||||
gridTemplateRows: rowTemplate,
|
||||
gridAutoRows: autoRowHeight,
|
||||
gap: `${finalGap}px`,
|
||||
padding: `${finalPadding}px`,
|
||||
minHeight: "100%",
|
||||
backgroundColor: "#ffffff",
|
||||
position: "relative",
|
||||
}), [breakpoint, finalGap, finalPadding, dynamicRowCount]);
|
||||
}), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
|
||||
|
||||
// 그리드 가이드 셀 생성 (동적 행 수)
|
||||
const gridCells = useMemo(() => {
|
||||
|
|
@ -248,15 +273,17 @@ export default function PopRenderer({
|
|||
onComponentMove={onComponentMove}
|
||||
onComponentResize={onComponentResize}
|
||||
onComponentResizeEnd={onComponentResizeEnd}
|
||||
onRequestResize={onRequestResize}
|
||||
previewPageIndex={previewPageIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 뷰어 모드: 드래그 없는 일반 렌더링
|
||||
// 뷰어 모드: 드래그 없는 일반 렌더링 (overflow visible로 컨텐츠 확장 허용)
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="relative rounded-lg border-2 border-gray-200 bg-white transition-all overflow-hidden z-10"
|
||||
className="relative overflow-hidden rounded-lg border-2 border-gray-200 bg-white transition-all z-10"
|
||||
style={positionStyle}
|
||||
>
|
||||
<ComponentContent
|
||||
|
|
@ -264,6 +291,8 @@ export default function PopRenderer({
|
|||
effectivePosition={position}
|
||||
isDesignMode={false}
|
||||
isSelected={false}
|
||||
onRequestResize={onRequestResize}
|
||||
screenId={currentScreenId ? String(currentScreenId) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -291,6 +320,8 @@ interface DraggableComponentProps {
|
|||
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||
onComponentResizeEnd?: (componentId: string) => void;
|
||||
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||
previewPageIndex?: number;
|
||||
}
|
||||
|
||||
function DraggableComponent({
|
||||
|
|
@ -308,6 +339,8 @@ function DraggableComponent({
|
|||
onComponentMove,
|
||||
onComponentResize,
|
||||
onComponentResizeEnd,
|
||||
onRequestResize,
|
||||
previewPageIndex,
|
||||
}: DraggableComponentProps) {
|
||||
const [{ isDragging }, drag] = useDrag(
|
||||
() => ({
|
||||
|
|
@ -346,6 +379,9 @@ function DraggableComponent({
|
|||
effectivePosition={position}
|
||||
isDesignMode={isDesignMode}
|
||||
isSelected={isSelected}
|
||||
previewPageIndex={previewPageIndex}
|
||||
onRequestResize={onRequestResize}
|
||||
screenId={undefined}
|
||||
/>
|
||||
|
||||
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
|
||||
|
|
@ -496,66 +532,99 @@ interface ComponentContentProps {
|
|||
effectivePosition: PopGridPosition;
|
||||
isDesignMode: boolean;
|
||||
isSelected: boolean;
|
||||
previewPageIndex?: number;
|
||||
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||
/** 화면 ID (이벤트 버스/액션 실행용) */
|
||||
screenId?: string;
|
||||
}
|
||||
|
||||
function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) {
|
||||
function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex, onRequestResize, screenId }: ComponentContentProps) {
|
||||
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||
|
||||
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
|
||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||
const PreviewComponent = registeredComp?.preview;
|
||||
|
||||
// 디자인 모드: 미리보기 컴포넌트 또는 플레이스홀더 표시
|
||||
// 디자인 모드: 실제 컴포넌트 또는 미리보기 표시 (헤더 없음 - 뷰어와 동일하게)
|
||||
if (isDesignMode) {
|
||||
const ActualComp = registeredComp?.component;
|
||||
|
||||
// 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등)
|
||||
if (ActualComp) {
|
||||
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
|
||||
// CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용
|
||||
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list";
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"h-full w-full overflow-hidden",
|
||||
!needsPointerEvents && "pointer-events-none"
|
||||
)}>
|
||||
<ActualComp
|
||||
config={component.config}
|
||||
label={component.label}
|
||||
isDesignMode={isDesignMode}
|
||||
previewPageIndex={previewPageIndex}
|
||||
componentId={component.id}
|
||||
screenId={screenId}
|
||||
currentRowSpan={effectivePosition.rowSpan}
|
||||
currentColSpan={effectivePosition.colSpan}
|
||||
onRequestResize={onRequestResize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 미등록: preview 컴포넌트 또는 기본 플레이스홀더
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 shrink-0 items-center border-b px-2",
|
||||
isSelected ? "bg-primary/10 border-primary" : "bg-gray-50 border-gray-200"
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"text-[10px] font-medium truncate",
|
||||
isSelected ? "text-primary" : "text-gray-600"
|
||||
)}>
|
||||
{component.label || typeLabel}
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
||||
{PreviewComponent ? (
|
||||
<PreviewComponent config={component.config} />
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 p-2">
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */}
|
||||
<div className="flex flex-1 items-center justify-center overflow-hidden">
|
||||
{PreviewComponent ? (
|
||||
<PreviewComponent config={component.config} />
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 p-2">
|
||||
{typeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 위치 정보 표시 (유효 위치 사용) */}
|
||||
<div className="absolute bottom-1 right-1 text-[9px] text-gray-400 bg-white/80 px-1 rounded">
|
||||
{effectivePosition.col},{effectivePosition.row}
|
||||
({effectivePosition.colSpan}×{effectivePosition.rowSpan})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 실제 모드: 컴포넌트 렌더링
|
||||
return renderActualComponent(component);
|
||||
// 실제 모드: 컴포넌트 렌더링 (뷰어 모드에서도 리사이즈 지원)
|
||||
return renderActualComponent(component, effectivePosition, onRequestResize, screenId);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 실제 컴포넌트 렌더링 (뷰어 모드)
|
||||
// ========================================
|
||||
|
||||
function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode {
|
||||
const typeLabel = COMPONENT_TYPE_LABELS[component.type];
|
||||
function renderActualComponent(
|
||||
component: PopComponentDefinitionV5,
|
||||
effectivePosition?: PopGridPosition,
|
||||
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void,
|
||||
screenId?: string,
|
||||
): React.ReactNode {
|
||||
// 레지스트리에서 등록된 실제 컴포넌트 조회
|
||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||
const ActualComp = registeredComp?.component;
|
||||
|
||||
// 샘플 박스 렌더링
|
||||
if (ActualComp) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<ActualComp
|
||||
config={component.config}
|
||||
label={component.label}
|
||||
componentId={component.id}
|
||||
screenId={screenId}
|
||||
currentRowSpan={effectivePosition?.rowSpan}
|
||||
currentColSpan={effectivePosition?.colSpan}
|
||||
onRequestResize={onRequestResize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 미등록 컴포넌트: 플레이스홀더 (fallback)
|
||||
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-2">
|
||||
<span className="text-xs text-gray-500">{component.label || typeLabel}</span>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
/**
|
||||
* POP 컴포넌트 타입
|
||||
*/
|
||||
export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search";
|
||||
|
||||
/**
|
||||
* 데이터 흐름 정의
|
||||
|
|
@ -25,6 +25,16 @@ export interface PopDataConnection {
|
|||
targetComponent: string;
|
||||
targetField: string;
|
||||
transformType?: "direct" | "calculate" | "lookup";
|
||||
|
||||
// v2: 연결 시스템 전용
|
||||
sourceOutput?: string;
|
||||
targetInput?: string;
|
||||
filterConfig?: {
|
||||
targetColumn: string;
|
||||
targetColumns?: string[];
|
||||
filterMode: "equals" | "contains" | "starts_with" | "range";
|
||||
};
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -208,6 +218,9 @@ export interface PopLayoutDataV5 {
|
|||
mobile_landscape?: PopModeOverrideV5;
|
||||
tablet_portrait?: PopModeOverrideV5;
|
||||
};
|
||||
|
||||
// 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성)
|
||||
modals?: PopModalDefinition[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -342,6 +355,12 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
|
|||
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
||||
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-text": { colSpan: 3, rowSpan: 1 },
|
||||
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
||||
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
||||
"pop-card-list": { colSpan: 4, rowSpan: 3 },
|
||||
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
||||
"pop-search": { colSpan: 4, rowSpan: 2 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -380,6 +399,95 @@ export const addComponentToV5Layout = (
|
|||
return newLayout;
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 모달 캔버스 정의
|
||||
// ========================================
|
||||
|
||||
// ========================================
|
||||
// 모달 사이즈 시스템
|
||||
// ========================================
|
||||
|
||||
/** 모달 사이즈 프리셋 */
|
||||
export type ModalSizePreset = "sm" | "md" | "lg" | "xl" | "full";
|
||||
|
||||
/** 모달 사이즈 프리셋별 픽셀 값 */
|
||||
export const MODAL_SIZE_PRESETS: Record<ModalSizePreset, { width: number; label: string }> = {
|
||||
sm: { width: 400, label: "Small (400px)" },
|
||||
md: { width: 600, label: "Medium (600px)" },
|
||||
lg: { width: 800, label: "Large (800px)" },
|
||||
xl: { width: 1000, label: "XLarge (1000px)" },
|
||||
full: { width: 9999, label: "Full (화면 꽉 참)" },
|
||||
};
|
||||
|
||||
/** 모달 사이즈 설정 (모드별 독립 설정 가능) */
|
||||
export interface ModalSizeConfig {
|
||||
/** 기본 사이즈 (모든 모드 공통, 기본값: "md") */
|
||||
default: ModalSizePreset;
|
||||
/** 모드별 오버라이드 (미설정 시 default 사용) */
|
||||
modeOverrides?: {
|
||||
mobile_portrait?: ModalSizePreset;
|
||||
mobile_landscape?: ModalSizePreset;
|
||||
tablet_portrait?: ModalSizePreset;
|
||||
tablet_landscape?: ModalSizePreset;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 모드에서 모달의 실제 픽셀 너비를 계산
|
||||
* - 뷰포트보다 모달이 크면 자동으로 뷰포트에 맞춤 (full 승격)
|
||||
*/
|
||||
export function resolveModalWidth(
|
||||
sizeConfig: ModalSizeConfig | undefined,
|
||||
mode: GridMode,
|
||||
viewportWidth: number,
|
||||
): number {
|
||||
const preset = sizeConfig?.modeOverrides?.[mode] ?? sizeConfig?.default ?? "md";
|
||||
const presetEntry = MODAL_SIZE_PRESETS[preset] ?? MODAL_SIZE_PRESETS.md;
|
||||
const presetWidth = presetEntry.width;
|
||||
// full이면 뷰포트 전체, 아니면 프리셋과 뷰포트 중 작은 값
|
||||
if (preset === "full") return viewportWidth;
|
||||
return Math.min(presetWidth, viewportWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 캔버스 정의
|
||||
*
|
||||
* 버튼의 "모달 열기" 액션이 참조하는 모달 화면.
|
||||
* 메인 캔버스와 동일한 그리드 시스템을 사용.
|
||||
* 중첩 모달: parentId로 부모-자식 관계 표현.
|
||||
*/
|
||||
export interface PopModalDefinition {
|
||||
/** 모달 고유 ID (예: "modal-1", "modal-1-1") */
|
||||
id: string;
|
||||
/** 부모 모달 ID (최상위 모달은 undefined) */
|
||||
parentId?: string;
|
||||
/** 모달 제목 (다이얼로그 헤더에 표시) */
|
||||
title: string;
|
||||
/** 이 모달을 연 버튼의 컴포넌트 ID */
|
||||
sourceButtonId: string;
|
||||
/** 모달 내부 그리드 설정 */
|
||||
gridConfig: PopGridConfig;
|
||||
/** 모달 내부 컴포넌트 */
|
||||
components: Record<string, PopComponentDefinitionV5>;
|
||||
/** 모드별 오버라이드 */
|
||||
overrides?: {
|
||||
mobile_portrait?: PopModeOverrideV5;
|
||||
mobile_landscape?: PopModeOverrideV5;
|
||||
tablet_portrait?: PopModeOverrideV5;
|
||||
};
|
||||
/** 모달 프레임 설정 (닫기 방식) */
|
||||
frameConfig?: {
|
||||
/** 닫기(X) 버튼 표시 여부 (기본 true) */
|
||||
showCloseButton?: boolean;
|
||||
/** 오버레이 클릭으로 닫기 (기본 true) */
|
||||
closeOnOverlay?: boolean;
|
||||
/** ESC 키로 닫기 (기본 true) */
|
||||
closeOnEsc?: boolean;
|
||||
};
|
||||
/** 모달 사이즈 설정 (미설정 시 md 기본) */
|
||||
sizeConfig?: ModalSizeConfig;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 레거시 타입 별칭 (하위 호환 - 추후 제거)
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* PopViewerWithModals - 뷰어 모드에서 모달 렌더링 래퍼
|
||||
*
|
||||
* PopRenderer를 감싸서:
|
||||
* 1. __pop_modal_open__ 이벤트 구독 → Dialog 열기
|
||||
* 2. __pop_modal_close__ 이벤트 구독 → Dialog 닫기
|
||||
* 3. 모달 스택 관리 (중첩 모달 지원)
|
||||
*
|
||||
* 모달 내부는 또 다른 PopRenderer로 렌더링 (독립 그리드).
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import PopRenderer from "../designer/renderers/PopRenderer";
|
||||
import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout";
|
||||
import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout";
|
||||
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||
import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver";
|
||||
|
||||
// ========================================
|
||||
// 타입
|
||||
// ========================================
|
||||
|
||||
interface PopViewerWithModalsProps {
|
||||
/** 전체 레이아웃 (모달 정의 포함) */
|
||||
layout: PopLayoutDataV5;
|
||||
/** 뷰포트 너비 */
|
||||
viewportWidth: number;
|
||||
/** 화면 ID (이벤트 버스용) */
|
||||
screenId: string;
|
||||
/** 현재 그리드 모드 (PopRenderer 전달용) */
|
||||
currentMode?: GridMode;
|
||||
/** Gap 오버라이드 */
|
||||
overrideGap?: number;
|
||||
/** Padding 오버라이드 */
|
||||
overridePadding?: number;
|
||||
}
|
||||
|
||||
/** 열린 모달 상태 */
|
||||
interface OpenModal {
|
||||
definition: PopModalDefinition;
|
||||
returnTo?: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 메인 컴포넌트
|
||||
// ========================================
|
||||
|
||||
export default function PopViewerWithModals({
|
||||
layout,
|
||||
viewportWidth,
|
||||
screenId,
|
||||
currentMode,
|
||||
overrideGap,
|
||||
overridePadding,
|
||||
}: PopViewerWithModalsProps) {
|
||||
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
|
||||
const { subscribe, publish } = usePopEvent(screenId);
|
||||
|
||||
// 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
|
||||
const stableConnections = useMemo(
|
||||
() => layout.dataFlow?.connections ?? [],
|
||||
[layout.dataFlow?.connections]
|
||||
);
|
||||
useConnectionResolver({
|
||||
screenId,
|
||||
connections: stableConnections,
|
||||
});
|
||||
|
||||
// 모달 열기/닫기 이벤트 구독
|
||||
useEffect(() => {
|
||||
const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => {
|
||||
const data = payload as {
|
||||
modalId?: string;
|
||||
title?: string;
|
||||
mode?: string;
|
||||
returnTo?: string;
|
||||
};
|
||||
|
||||
if (data?.modalId) {
|
||||
const modalDef = layout.modals?.find(m => m.id === data.modalId);
|
||||
if (modalDef) {
|
||||
setModalStack(prev => [...prev, {
|
||||
definition: modalDef,
|
||||
returnTo: data.returnTo,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const unsubClose = subscribe("__pop_modal_close__", (payload: unknown) => {
|
||||
const data = payload as { selectedRow?: Record<string, unknown> } | undefined;
|
||||
|
||||
setModalStack(prev => {
|
||||
if (prev.length === 0) return prev;
|
||||
const topModal = prev[prev.length - 1];
|
||||
|
||||
// 결과 데이터가 있고, 반환 대상이 지정된 경우 결과 이벤트 발행
|
||||
if (data?.selectedRow && topModal.returnTo) {
|
||||
publish("__pop_modal_result__", {
|
||||
selectedRow: data.selectedRow,
|
||||
returnTo: topModal.returnTo,
|
||||
});
|
||||
}
|
||||
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubOpen();
|
||||
unsubClose();
|
||||
};
|
||||
}, [subscribe, publish, layout.modals]);
|
||||
|
||||
// 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC)
|
||||
const handleCloseTopModal = useCallback(() => {
|
||||
setModalStack(prev => prev.slice(0, -1));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 메인 화면 렌더링 */}
|
||||
<PopRenderer
|
||||
layout={layout}
|
||||
viewportWidth={viewportWidth}
|
||||
currentScreenId={Number(screenId) || undefined}
|
||||
currentMode={currentMode}
|
||||
isDesignMode={false}
|
||||
overrideGap={overrideGap}
|
||||
overridePadding={overridePadding}
|
||||
/>
|
||||
|
||||
{/* 모달 스택 렌더링 */}
|
||||
{modalStack.map((modal, index) => {
|
||||
const { definition } = modal;
|
||||
const isTopModal = index === modalStack.length - 1;
|
||||
const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false;
|
||||
const closeOnEsc = definition.frameConfig?.closeOnEsc !== false;
|
||||
|
||||
const modalLayout: PopLayoutDataV5 = {
|
||||
...layout,
|
||||
gridConfig: definition.gridConfig,
|
||||
components: definition.components,
|
||||
overrides: definition.overrides,
|
||||
};
|
||||
|
||||
const detectedMode = currentMode || detectGridMode(viewportWidth);
|
||||
const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
|
||||
const isFull = modalWidth >= viewportWidth;
|
||||
const rendererWidth = isFull ? viewportWidth : modalWidth - 32;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
key={`${definition.id}-${index}`}
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && isTopModal) handleCloseTopModal();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={isFull
|
||||
? "h-dvh max-h-dvh w-screen max-w-[100vw] overflow-auto rounded-none border-none p-0"
|
||||
: "max-h-[90vh] overflow-auto p-0"
|
||||
}
|
||||
style={isFull ? undefined : {
|
||||
maxWidth: `${modalWidth}px`,
|
||||
width: `${modalWidth}px`,
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
// 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지)
|
||||
if (!isTopModal || !closeOnOverlay) e.preventDefault();
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (!isTopModal || !closeOnEsc) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader className={isFull ? "px-4 pt-3 pb-2" : "px-4 pt-4 pb-2"}>
|
||||
<DialogTitle className="text-base">
|
||||
{definition.title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className={isFull ? "flex-1 overflow-auto" : "px-4 pb-4"}>
|
||||
<PopRenderer
|
||||
layout={modalLayout}
|
||||
viewportWidth={rendererWidth}
|
||||
currentScreenId={Number(screenId) || undefined}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* executePopAction - POP 액션 실행 순수 함수
|
||||
*
|
||||
* pop-button, pop-string-list 등 여러 컴포넌트에서 재사용 가능한
|
||||
* 액션 실행 코어 로직. React 훅에 의존하지 않음.
|
||||
*
|
||||
* 사용처:
|
||||
* - usePopAction 훅 (pop-button용 래퍼)
|
||||
* - pop-string-list 카드 버튼 (직접 호출)
|
||||
* - 향후 pop-table 행 액션 등
|
||||
*/
|
||||
|
||||
import type { ButtonMainAction } from "@/lib/registry/pop-components/pop-button";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
|
||||
// ========================================
|
||||
// 타입 정의
|
||||
// ========================================
|
||||
|
||||
/** 액션 실행 결과 */
|
||||
export interface ActionResult {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 이벤트 발행 함수 시그니처 (usePopEvent의 publish와 동일) */
|
||||
type PublishFn = (eventName: string, payload?: unknown) => void;
|
||||
|
||||
/** executePopAction 옵션 */
|
||||
interface ExecuteOptions {
|
||||
/** 필드 매핑 (소스 컬럼명 → 타겟 컬럼명) */
|
||||
fieldMapping?: Record<string, string>;
|
||||
/** 화면 ID (이벤트 발행 시 사용) */
|
||||
screenId?: string;
|
||||
/** 이벤트 발행 함수 (순수 함수이므로 외부에서 주입) */
|
||||
publish?: PublishFn;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 내부 헬퍼
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 필드 매핑 적용
|
||||
* 소스 데이터의 컬럼명을 타겟 테이블 컬럼명으로 변환
|
||||
*/
|
||||
function applyFieldMapping(
|
||||
rowData: Record<string, unknown>,
|
||||
mapping?: Record<string, string>
|
||||
): Record<string, unknown> {
|
||||
if (!mapping || Object.keys(mapping).length === 0) {
|
||||
return { ...rowData };
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [sourceKey, value] of Object.entries(rowData)) {
|
||||
// 매핑이 있으면 타겟 키로 변환, 없으면 원본 키 유지
|
||||
const targetKey = mapping[sourceKey] || sourceKey;
|
||||
result[targetKey] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* rowData에서 PK 추출
|
||||
* id > pk 순서로 시도, 없으면 rowData 전체를 복합키로 사용
|
||||
*/
|
||||
function extractPrimaryKey(
|
||||
rowData: Record<string, unknown>
|
||||
): string | number | Record<string, unknown> {
|
||||
if (rowData.id != null) return rowData.id as string | number;
|
||||
if (rowData.pk != null) return rowData.pk as string | number;
|
||||
// 복합키: rowData 전체를 Record로 전달 (dataApi.deleteRecord가 object 지원)
|
||||
return rowData as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 메인 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* POP 액션 실행 (순수 함수)
|
||||
*
|
||||
* @param action - 버튼 메인 액션 설정
|
||||
* @param rowData - 대상 행 데이터 (리스트 컴포넌트에서 전달)
|
||||
* @param options - 필드 매핑, screenId, publish 함수
|
||||
* @returns 실행 결과
|
||||
*/
|
||||
export async function executePopAction(
|
||||
action: ButtonMainAction,
|
||||
rowData?: Record<string, unknown>,
|
||||
options?: ExecuteOptions
|
||||
): Promise<ActionResult> {
|
||||
const { fieldMapping, publish } = options || {};
|
||||
|
||||
try {
|
||||
switch (action.type) {
|
||||
// ── 저장 ──
|
||||
case "save": {
|
||||
if (!action.targetTable) {
|
||||
return { success: false, error: "저장 대상 테이블이 설정되지 않았습니다." };
|
||||
}
|
||||
const data = rowData
|
||||
? applyFieldMapping(rowData, fieldMapping)
|
||||
: {};
|
||||
const result = await dataApi.createRecord(action.targetTable, data);
|
||||
return { success: !!result?.success, data: result?.data, error: result?.message };
|
||||
}
|
||||
|
||||
// ── 삭제 ──
|
||||
case "delete": {
|
||||
if (!action.targetTable) {
|
||||
return { success: false, error: "삭제 대상 테이블이 설정되지 않았습니다." };
|
||||
}
|
||||
if (!rowData) {
|
||||
return { success: false, error: "삭제할 데이터가 없습니다." };
|
||||
}
|
||||
const mappedData = applyFieldMapping(rowData, fieldMapping);
|
||||
const pk = extractPrimaryKey(mappedData);
|
||||
const result = await dataApi.deleteRecord(action.targetTable, pk);
|
||||
return { success: !!result?.success, error: result?.message };
|
||||
}
|
||||
|
||||
// ── API 호출 ──
|
||||
case "api": {
|
||||
if (!action.apiEndpoint) {
|
||||
return { success: false, error: "API 엔드포인트가 설정되지 않았습니다." };
|
||||
}
|
||||
const body = rowData
|
||||
? applyFieldMapping(rowData, fieldMapping)
|
||||
: undefined;
|
||||
const method = (action.apiMethod || "POST").toUpperCase();
|
||||
|
||||
let response;
|
||||
switch (method) {
|
||||
case "GET":
|
||||
response = await apiClient.get(action.apiEndpoint, { params: body });
|
||||
break;
|
||||
case "POST":
|
||||
response = await apiClient.post(action.apiEndpoint, body);
|
||||
break;
|
||||
case "PUT":
|
||||
response = await apiClient.put(action.apiEndpoint, body);
|
||||
break;
|
||||
case "DELETE":
|
||||
response = await apiClient.delete(action.apiEndpoint, { data: body });
|
||||
break;
|
||||
default:
|
||||
response = await apiClient.post(action.apiEndpoint, body);
|
||||
}
|
||||
|
||||
const resData = response?.data;
|
||||
return {
|
||||
success: resData?.success !== false,
|
||||
data: resData?.data ?? resData,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 모달 열기 ──
|
||||
case "modal": {
|
||||
if (!publish) {
|
||||
return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." };
|
||||
}
|
||||
publish("__pop_modal_open__", {
|
||||
modalId: action.modalScreenId,
|
||||
title: action.modalTitle,
|
||||
mode: action.modalMode,
|
||||
items: action.modalItems,
|
||||
rowData,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── 이벤트 발행 ──
|
||||
case "event": {
|
||||
if (!publish) {
|
||||
return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." };
|
||||
}
|
||||
if (!action.eventName) {
|
||||
return { success: false, error: "이벤트 이름이 설정되지 않았습니다." };
|
||||
}
|
||||
publish(action.eventName, {
|
||||
...(action.eventPayload || {}),
|
||||
row: rowData,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
default:
|
||||
return { success: false, error: `알 수 없는 액션 타입: ${action.type}` };
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다.";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* POP 공통 훅 배럴 파일
|
||||
*
|
||||
* 사용법: import { usePopEvent, useDataSource } from "@/hooks/pop";
|
||||
*/
|
||||
|
||||
// 이벤트 통신 훅
|
||||
export { usePopEvent, cleanupScreen } from "./usePopEvent";
|
||||
|
||||
// 데이터 CRUD 훅
|
||||
export { useDataSource } from "./useDataSource";
|
||||
export type { MutationResult, DataSourceResult } from "./useDataSource";
|
||||
|
||||
// 액션 실행 순수 함수
|
||||
export { executePopAction } from "./executePopAction";
|
||||
export type { ActionResult } from "./executePopAction";
|
||||
|
||||
// 액션 실행 React 훅
|
||||
export { usePopAction } from "./usePopAction";
|
||||
export type { PendingConfirmState } from "./usePopAction";
|
||||
|
||||
// 연결 해석기
|
||||
export { useConnectionResolver } from "./useConnectionResolver";
|
||||
|
||||
// SQL 빌더 유틸 (고급 사용 시)
|
||||
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* POP 공통 SQL 빌더
|
||||
*
|
||||
* DataSourceConfig를 SQL 문자열로 변환하는 순수 유틸리티.
|
||||
* 원본: pop-dashboard/utils/dataFetcher.ts에서 추출 (로직 동일).
|
||||
*
|
||||
* 대시보드(dataFetcher.ts)는 기존 코드를 그대로 유지하고,
|
||||
* 새 컴포넌트(useDataSource 등)는 이 파일을 사용한다.
|
||||
* 향후 대시보드 교체 시 dataFetcher.ts가 이 파일을 import하도록 변경 예정.
|
||||
*
|
||||
* 보안:
|
||||
* - escapeSQL: SQL 인젝션 방지 (문자열 이스케이프)
|
||||
* - sanitizeIdentifier: 테이블명/컬럼명에서 위험 문자 제거
|
||||
*/
|
||||
|
||||
import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types";
|
||||
|
||||
// ===== SQL 값 이스케이프 =====
|
||||
|
||||
/** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */
|
||||
function escapeSQL(value: unknown): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
||||
// 문자열: 작은따옴표 이스케이프
|
||||
const str = String(value).replace(/'/g, "''");
|
||||
return `'${str}'`;
|
||||
}
|
||||
|
||||
// ===== 식별자 검증 (테이블명, 컬럼명) =====
|
||||
|
||||
/** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */
|
||||
function sanitizeIdentifier(name: string): string {
|
||||
// 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용
|
||||
return name.replace(/[^a-zA-Z0-9_.]/g, "");
|
||||
}
|
||||
|
||||
// ===== 설정 완료 여부 검증 =====
|
||||
|
||||
/**
|
||||
* DataSourceConfig의 필수값이 모두 채워졌는지 검증
|
||||
* 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는
|
||||
* SQL을 생성하지 않도록 사전 차단
|
||||
*
|
||||
* @returns null이면 유효, 문자열이면 미완료 사유
|
||||
*/
|
||||
export function validateDataSourceConfig(config: DataSourceConfig): string | null {
|
||||
// 테이블명 필수
|
||||
if (!config.tableName || !config.tableName.trim()) {
|
||||
return "테이블이 선택되지 않았습니다";
|
||||
}
|
||||
|
||||
// 집계 함수가 설정되었으면 대상 컬럼도 필수
|
||||
// (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능)
|
||||
if (config.aggregation) {
|
||||
const aggType = config.aggregation.type?.toLowerCase();
|
||||
const aggCol = config.aggregation.column?.trim();
|
||||
if (aggType !== "count" && !aggCol) {
|
||||
return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`;
|
||||
}
|
||||
}
|
||||
|
||||
// 조인이 있으면 조인 조건 필수
|
||||
if (config.joins?.length) {
|
||||
for (const join of config.joins) {
|
||||
if (!join.targetTable?.trim()) {
|
||||
return "조인 대상 테이블이 선택되지 않았습니다";
|
||||
}
|
||||
if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
|
||||
return "조인 조건 컬럼이 설정되지 않았습니다";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===== 필터 조건 SQL 생성 =====
|
||||
|
||||
/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */
|
||||
function buildWhereClause(filters: DataSourceFilter[]): string {
|
||||
// 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어)
|
||||
const validFilters = filters.filter((f) => f.column?.trim());
|
||||
if (!validFilters.length) return "";
|
||||
|
||||
const conditions = validFilters.map((f) => {
|
||||
const col = sanitizeIdentifier(f.column);
|
||||
|
||||
switch (f.operator) {
|
||||
case "between": {
|
||||
const arr = Array.isArray(f.value) ? f.value : [f.value, f.value];
|
||||
return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`;
|
||||
}
|
||||
case "in": {
|
||||
const arr = Array.isArray(f.value) ? f.value : [f.value];
|
||||
const vals = arr.map(escapeSQL).join(", ");
|
||||
return `${col} IN (${vals})`;
|
||||
}
|
||||
case "like":
|
||||
return `${col} LIKE ${escapeSQL(f.value)}`;
|
||||
default:
|
||||
return `${col} ${f.operator} ${escapeSQL(f.value)}`;
|
||||
}
|
||||
});
|
||||
|
||||
return `WHERE ${conditions.join(" AND ")}`;
|
||||
}
|
||||
|
||||
// ===== 집계 SQL 빌더 =====
|
||||
|
||||
/**
|
||||
* DataSourceConfig를 SELECT SQL로 변환
|
||||
*
|
||||
* @param config - 데이터 소스 설정
|
||||
* @returns SQL 문자열
|
||||
*/
|
||||
export function buildAggregationSQL(config: DataSourceConfig): string {
|
||||
const tableName = sanitizeIdentifier(config.tableName);
|
||||
|
||||
// SELECT 절
|
||||
let selectClause: string;
|
||||
if (config.aggregation) {
|
||||
const aggType = config.aggregation.type.toUpperCase();
|
||||
const aggCol = config.aggregation.column?.trim()
|
||||
? sanitizeIdentifier(config.aggregation.column)
|
||||
: "";
|
||||
|
||||
// COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수
|
||||
if (!aggCol) {
|
||||
selectClause = aggType === "COUNT"
|
||||
? "COUNT(*) as value"
|
||||
: `${aggType}(${tableName}.*) as value`;
|
||||
} else {
|
||||
selectClause = `${aggType}(${aggCol}) as value`;
|
||||
}
|
||||
|
||||
// GROUP BY가 있으면 해당 컬럼도 SELECT에 포함
|
||||
if (config.aggregation.groupBy?.length) {
|
||||
const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", ");
|
||||
selectClause = `${groupCols}, ${selectClause}`;
|
||||
}
|
||||
} else {
|
||||
selectClause = "*";
|
||||
}
|
||||
|
||||
// FROM 절 (조인 포함 - 조건이 완전한 조인만 적용)
|
||||
let fromClause = tableName;
|
||||
if (config.joins?.length) {
|
||||
for (const join of config.joins) {
|
||||
// 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어)
|
||||
if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
|
||||
continue;
|
||||
}
|
||||
const joinTable = sanitizeIdentifier(join.targetTable);
|
||||
const joinType = join.joinType.toUpperCase();
|
||||
const srcCol = sanitizeIdentifier(join.on.sourceColumn);
|
||||
const tgtCol = sanitizeIdentifier(join.on.targetColumn);
|
||||
fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`;
|
||||
}
|
||||
}
|
||||
|
||||
// WHERE 절
|
||||
const whereClause = config.filters?.length
|
||||
? buildWhereClause(config.filters)
|
||||
: "";
|
||||
|
||||
// GROUP BY 절
|
||||
let groupByClause = "";
|
||||
if (config.aggregation?.groupBy?.length) {
|
||||
groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`;
|
||||
}
|
||||
|
||||
// ORDER BY 절
|
||||
let orderByClause = "";
|
||||
if (config.sort?.length) {
|
||||
const sortCols = config.sort
|
||||
.map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`)
|
||||
.join(", ");
|
||||
orderByClause = `ORDER BY ${sortCols}`;
|
||||
}
|
||||
|
||||
// LIMIT 절
|
||||
const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : "";
|
||||
|
||||
return [
|
||||
`SELECT ${selectClause}`,
|
||||
`FROM ${fromClause}`,
|
||||
whereClause,
|
||||
groupByClause,
|
||||
orderByClause,
|
||||
limitClause,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* useConnectionResolver - 런타임 컴포넌트 연결 해석기
|
||||
*
|
||||
* PopViewerWithModals에서 사용.
|
||||
* layout.dataFlow.connections를 읽고, 소스 컴포넌트의 __comp_output__ 이벤트를
|
||||
* 타겟 컴포넌트의 __comp_input__ 이벤트로 자동 변환/중계한다.
|
||||
*
|
||||
* 이벤트 규칙:
|
||||
* 소스: __comp_output__${sourceComponentId}__${outputKey}
|
||||
* 타겟: __comp_input__${targetComponentId}__${inputKey}
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePopEvent } from "./usePopEvent";
|
||||
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
|
||||
|
||||
interface UseConnectionResolverOptions {
|
||||
screenId: string;
|
||||
connections: PopDataConnection[];
|
||||
}
|
||||
|
||||
export function useConnectionResolver({
|
||||
screenId,
|
||||
connections,
|
||||
}: UseConnectionResolverOptions): void {
|
||||
const { publish, subscribe } = usePopEvent(screenId);
|
||||
|
||||
// 연결 목록을 ref로 저장하여 콜백 안정성 확보
|
||||
const connectionsRef = useRef(connections);
|
||||
connectionsRef.current = connections;
|
||||
|
||||
useEffect(() => {
|
||||
if (!connections || connections.length === 0) return;
|
||||
|
||||
const unsubscribers: (() => void)[] = [];
|
||||
|
||||
// 소스별로 그룹핑하여 구독 생성
|
||||
const sourceGroups = new Map<string, PopDataConnection[]>();
|
||||
for (const conn of connections) {
|
||||
const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`;
|
||||
const existing = sourceGroups.get(sourceEvent) || [];
|
||||
existing.push(conn);
|
||||
sourceGroups.set(sourceEvent, existing);
|
||||
}
|
||||
|
||||
for (const [sourceEvent, conns] of sourceGroups) {
|
||||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||
for (const conn of conns) {
|
||||
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
|
||||
|
||||
// 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId }
|
||||
const enrichedPayload = {
|
||||
value: payload,
|
||||
filterConfig: conn.filterConfig,
|
||||
_connectionId: conn.id,
|
||||
};
|
||||
|
||||
publish(targetEvent, enrichedPayload);
|
||||
}
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const unsub of unsubscribers) {
|
||||
unsub();
|
||||
}
|
||||
};
|
||||
}, [screenId, connections, subscribe, publish]);
|
||||
}
|
||||
|
|
@ -0,0 +1,383 @@
|
|||
/**
|
||||
* useDataSource - POP 컴포넌트용 데이터 CRUD 통합 훅
|
||||
*
|
||||
* DataSourceConfig를 받아서 자동으로 적절한 API를 선택하여 데이터를 조회/생성/수정/삭제한다.
|
||||
*
|
||||
* 조회 분기:
|
||||
* - aggregation 또는 joins가 있으면 → SQL 빌더 + executeQuery (대시보드와 동일)
|
||||
* - 그 외 → dataApi.getTableData (단순 테이블 조회)
|
||||
*
|
||||
* CRUD:
|
||||
* - save: dataApi.createRecord
|
||||
* - update: dataApi.updateRecord
|
||||
* - remove: dataApi.deleteRecord
|
||||
*
|
||||
* 사용 패턴:
|
||||
* ```typescript
|
||||
* // 집계 조회 (대시보드용)
|
||||
* const { data, loading } = useDataSource({
|
||||
* tableName: "sales_order",
|
||||
* aggregation: { type: "sum", column: "amount", groupBy: ["category"] },
|
||||
* refreshInterval: 30,
|
||||
* });
|
||||
*
|
||||
* // 단순 목록 조회 (테이블용)
|
||||
* const { data, refetch } = useDataSource({
|
||||
* tableName: "purchase_order",
|
||||
* sort: [{ column: "created_at", direction: "desc" }],
|
||||
* limit: 20,
|
||||
* });
|
||||
*
|
||||
* // 저장/삭제 (버튼용)
|
||||
* const { save, remove } = useDataSource({ tableName: "inbound_record" });
|
||||
* await save({ supplier_id: "SUP-001", quantity: 50 });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types";
|
||||
import { validateDataSourceConfig, buildAggregationSQL } from "./popSqlBuilder";
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
|
||||
/** 조회 결과 */
|
||||
export interface DataSourceResult {
|
||||
/** 데이터 행 배열 */
|
||||
rows: Record<string, unknown>[];
|
||||
/** 단일 집계 값 (aggregation 시) 또는 전체 행 수 */
|
||||
value: number;
|
||||
/** 전체 행 수 (페이징용) */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** CRUD 작업 결과 */
|
||||
export interface MutationResult {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** refetch 시 전달할 오버라이드 필터 */
|
||||
interface OverrideOptions {
|
||||
filters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ===== 내부: 집계/조인 조회 =====
|
||||
|
||||
/**
|
||||
* 집계 또는 조인이 포함된 DataSourceConfig를 SQL로 변환하여 실행
|
||||
* dataFetcher.ts의 fetchAggregatedData와 동일한 로직
|
||||
*/
|
||||
async function fetchWithSqlBuilder(
|
||||
config: DataSourceConfig
|
||||
): Promise<DataSourceResult> {
|
||||
const sql = buildAggregationSQL(config);
|
||||
|
||||
// API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백
|
||||
let queryResult: { columns: string[]; rows: Record<string, unknown>[] };
|
||||
try {
|
||||
// 1차: apiClient (axios 기반, 인증/세션 안정적)
|
||||
const response = await apiClient.post("/dashboards/execute-query", { query: sql });
|
||||
if (response.data?.success && response.data?.data) {
|
||||
queryResult = response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data?.message || "쿼리 실행 실패");
|
||||
}
|
||||
} catch {
|
||||
// 2차: dashboardApi (fetch 기반, 폴백)
|
||||
queryResult = await dashboardApi.executeQuery(sql);
|
||||
}
|
||||
|
||||
if (queryResult.rows.length === 0) {
|
||||
return { rows: [], value: 0, total: 0 };
|
||||
}
|
||||
|
||||
// PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨 → 숫자 변환
|
||||
const processedRows = queryResult.rows.map((row) => {
|
||||
const converted: Record<string, unknown> = { ...row };
|
||||
for (const key of Object.keys(converted)) {
|
||||
const val = converted[key];
|
||||
if (typeof val === "string" && val !== "" && !isNaN(Number(val))) {
|
||||
converted[key] = Number(val);
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
});
|
||||
|
||||
// 첫 번째 행의 value 컬럼 추출
|
||||
const firstRow = processedRows[0];
|
||||
const numericValue = parseFloat(
|
||||
String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0)
|
||||
);
|
||||
|
||||
return {
|
||||
rows: processedRows,
|
||||
value: Number.isFinite(numericValue) ? numericValue : 0,
|
||||
total: processedRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 내부: 단순 테이블 조회 =====
|
||||
|
||||
/**
|
||||
* aggregation/joins 없는 단순 테이블 조회
|
||||
* dataApi.getTableData 래핑
|
||||
*/
|
||||
async function fetchSimpleTable(
|
||||
config: DataSourceConfig,
|
||||
overrideFilters?: Record<string, unknown>
|
||||
): Promise<DataSourceResult> {
|
||||
// config.filters를 Record<string, unknown> 형태로 변환
|
||||
const baseFilters: Record<string, unknown> = {};
|
||||
if (config.filters?.length) {
|
||||
for (const f of config.filters) {
|
||||
if (f.column?.trim()) {
|
||||
baseFilters[f.column] = f.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// overrideFilters가 있으면 병합 (같은 키는 override가 덮어씀)
|
||||
const mergedFilters = overrideFilters
|
||||
? { ...baseFilters, ...overrideFilters }
|
||||
: baseFilters;
|
||||
|
||||
const tableResult = await dataApi.getTableData(config.tableName, {
|
||||
page: 1,
|
||||
size: config.limit ?? 100,
|
||||
sortBy: config.sort?.[0]?.column,
|
||||
sortOrder: config.sort?.[0]?.direction,
|
||||
filters: Object.keys(mergedFilters).length > 0 ? mergedFilters : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
rows: tableResult.data,
|
||||
value: tableResult.total ?? tableResult.data.length,
|
||||
total: tableResult.total ?? tableResult.data.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 내부: overrideFilters를 DataSourceFilter 배열에 병합 =====
|
||||
|
||||
/**
|
||||
* 기존 config에 overrideFilters를 병합한 새 config 생성
|
||||
* 같은 column이 있으면 override 값으로 대체
|
||||
*/
|
||||
function mergeFilters(
|
||||
config: DataSourceConfig,
|
||||
overrideFilters?: Record<string, unknown>
|
||||
): DataSourceConfig {
|
||||
if (!overrideFilters || Object.keys(overrideFilters).length === 0) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// 기존 filters에서 override 대상이 아닌 것만 유지
|
||||
const overrideColumns = new Set(Object.keys(overrideFilters));
|
||||
const existingFilters: DataSourceFilter[] = (config.filters ?? []).filter(
|
||||
(f) => !overrideColumns.has(f.column)
|
||||
);
|
||||
|
||||
// override를 DataSourceFilter로 변환하여 추가
|
||||
const newFilters: DataSourceFilter[] = Object.entries(overrideFilters).map(
|
||||
([column, value]) => ({
|
||||
column,
|
||||
operator: "=" as const,
|
||||
value,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...config,
|
||||
filters: [...existingFilters, ...newFilters],
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 메인 훅 =====
|
||||
|
||||
/**
|
||||
* POP 컴포넌트용 데이터 CRUD 통합 훅
|
||||
*
|
||||
* @param config - DataSourceConfig (tableName 필수)
|
||||
* @returns data, loading, error, refetch, save, update, remove
|
||||
*/
|
||||
export function useDataSource(config: DataSourceConfig) {
|
||||
const [data, setData] = useState<DataSourceResult>({
|
||||
rows: [],
|
||||
value: 0,
|
||||
total: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// config를 ref로 저장 (콜백 안정성)
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
// 자동 새로고침 타이머
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// ===== 조회 (READ) =====
|
||||
|
||||
const refetch = useCallback(
|
||||
async (options?: OverrideOptions): Promise<void> => {
|
||||
const currentConfig = configRef.current;
|
||||
|
||||
// 테이블명 없으면 조회하지 않음
|
||||
if (!currentConfig.tableName?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const hasAggregation = !!currentConfig.aggregation;
|
||||
const hasJoins = !!(currentConfig.joins && currentConfig.joins.length > 0);
|
||||
|
||||
let result: DataSourceResult;
|
||||
|
||||
if (hasAggregation || hasJoins) {
|
||||
// 집계/조인 → SQL 빌더 경로
|
||||
// 설정 완료 여부 검증
|
||||
const merged = mergeFilters(currentConfig, options?.filters);
|
||||
const validationError = validateDataSourceConfig(merged);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
result = await fetchWithSqlBuilder(merged);
|
||||
} else {
|
||||
// 단순 조회 → dataApi 경로
|
||||
result = await fetchSimpleTable(currentConfig, options?.filters);
|
||||
}
|
||||
|
||||
setData(result);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "데이터 조회 실패";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[] // configRef 사용으로 의존성 불필요
|
||||
);
|
||||
|
||||
// ===== 생성 (CREATE) =====
|
||||
|
||||
const save = useCallback(
|
||||
async (record: Record<string, unknown>): Promise<MutationResult> => {
|
||||
const tableName = configRef.current.tableName;
|
||||
if (!tableName?.trim()) {
|
||||
return { success: false, error: "테이블이 설정되지 않았습니다" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dataApi.createRecord(tableName, record);
|
||||
return {
|
||||
success: result.success ?? true,
|
||||
data: result.data,
|
||||
error: result.message,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "레코드 생성 실패";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== 수정 (UPDATE) =====
|
||||
|
||||
const update = useCallback(
|
||||
async (
|
||||
id: string | number,
|
||||
record: Record<string, unknown>
|
||||
): Promise<MutationResult> => {
|
||||
const tableName = configRef.current.tableName;
|
||||
if (!tableName?.trim()) {
|
||||
return { success: false, error: "테이블이 설정되지 않았습니다" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dataApi.updateRecord(tableName, id, record);
|
||||
return {
|
||||
success: result.success ?? true,
|
||||
data: result.data,
|
||||
error: result.message,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "레코드 수정 실패";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== 삭제 (DELETE) =====
|
||||
|
||||
const remove = useCallback(
|
||||
async (
|
||||
id: string | number | Record<string, unknown>
|
||||
): Promise<MutationResult> => {
|
||||
const tableName = configRef.current.tableName;
|
||||
if (!tableName?.trim()) {
|
||||
return { success: false, error: "테이블이 설정되지 않았습니다" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dataApi.deleteRecord(tableName, id);
|
||||
return {
|
||||
success: result.success ?? true,
|
||||
data: result.data,
|
||||
error: result.message,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "레코드 삭제 실패";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== 자동 조회 + 새로고침 =====
|
||||
|
||||
// config.tableName 또는 refreshInterval이 변경되면 재조회
|
||||
const tableName = config.tableName;
|
||||
const refreshInterval = config.refreshInterval;
|
||||
|
||||
useEffect(() => {
|
||||
// 테이블명 있으면 초기 조회
|
||||
if (tableName?.trim()) {
|
||||
refetch();
|
||||
}
|
||||
|
||||
// refreshInterval 설정 시 자동 새로고침
|
||||
if (refreshInterval && refreshInterval > 0) {
|
||||
const sec = Math.max(5, refreshInterval); // 최소 5초
|
||||
refreshTimerRef.current = setInterval(() => {
|
||||
refetch();
|
||||
}, sec * 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (refreshTimerRef.current) {
|
||||
clearInterval(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [tableName, refreshInterval, refetch]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
save,
|
||||
update,
|
||||
remove,
|
||||
} as const;
|
||||
}
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
/**
|
||||
* usePopAction - POP 액션 실행 React 훅
|
||||
*
|
||||
* executePopAction (순수 함수)를 래핑하여 React UI 관심사를 처리:
|
||||
* - 로딩 상태 (isLoading)
|
||||
* - 확인 다이얼로그 (pendingConfirm)
|
||||
* - 토스트 알림
|
||||
* - 후속 액션 체이닝 (followUpActions)
|
||||
*
|
||||
* 사용처:
|
||||
* - PopButtonComponent (메인 버튼)
|
||||
*
|
||||
* pop-string-list 등 리스트 컴포넌트는 executePopAction을 직접 호출하여
|
||||
* 훅 인스턴스 폭발 문제를 회피함.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import type {
|
||||
ButtonMainAction,
|
||||
FollowUpAction,
|
||||
ConfirmConfig,
|
||||
} from "@/lib/registry/pop-components/pop-button";
|
||||
import { usePopEvent } from "./usePopEvent";
|
||||
import { executePopAction } from "./executePopAction";
|
||||
import type { ActionResult } from "./executePopAction";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ========================================
|
||||
// 타입 정의
|
||||
// ========================================
|
||||
|
||||
/** 확인 대기 중인 액션 상태 */
|
||||
export interface PendingConfirmState {
|
||||
action: ButtonMainAction;
|
||||
rowData?: Record<string, unknown>;
|
||||
fieldMapping?: Record<string, string>;
|
||||
confirm: ConfirmConfig;
|
||||
followUpActions?: FollowUpAction[];
|
||||
}
|
||||
|
||||
/** execute 호출 시 옵션 */
|
||||
interface ExecuteActionOptions {
|
||||
/** 대상 행 데이터 */
|
||||
rowData?: Record<string, unknown>;
|
||||
/** 필드 매핑 */
|
||||
fieldMapping?: Record<string, string>;
|
||||
/** 확인 다이얼로그 설정 */
|
||||
confirm?: ConfirmConfig;
|
||||
/** 후속 액션 */
|
||||
followUpActions?: FollowUpAction[];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 상수
|
||||
// ========================================
|
||||
|
||||
/** 액션 성공 시 토스트 메시지 */
|
||||
const ACTION_SUCCESS_MESSAGES: Record<string, string> = {
|
||||
save: "저장되었습니다.",
|
||||
delete: "삭제되었습니다.",
|
||||
api: "요청이 완료되었습니다.",
|
||||
modal: "",
|
||||
event: "",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 메인 훅
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* POP 액션 실행 훅
|
||||
*
|
||||
* @param screenId - 화면 ID (이벤트 버스 연결용)
|
||||
* @returns execute, isLoading, pendingConfirm, confirmExecute, cancelConfirm
|
||||
*/
|
||||
export function usePopAction(screenId: string) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [pendingConfirm, setPendingConfirm] = useState<PendingConfirmState | null>(null);
|
||||
|
||||
const { publish } = usePopEvent(screenId);
|
||||
|
||||
// publish 안정성 보장 (콜백 내에서 최신 참조 사용)
|
||||
const publishRef = useRef(publish);
|
||||
publishRef.current = publish;
|
||||
|
||||
/**
|
||||
* 실제 실행 (확인 다이얼로그 이후 or 확인 불필요 시)
|
||||
*/
|
||||
const runAction = useCallback(
|
||||
async (
|
||||
action: ButtonMainAction,
|
||||
rowData?: Record<string, unknown>,
|
||||
fieldMapping?: Record<string, string>,
|
||||
followUpActions?: FollowUpAction[]
|
||||
): Promise<ActionResult> => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await executePopAction(action, rowData, {
|
||||
fieldMapping,
|
||||
screenId,
|
||||
publish: publishRef.current,
|
||||
});
|
||||
|
||||
// 결과에 따른 토스트
|
||||
if (result.success) {
|
||||
const msg = ACTION_SUCCESS_MESSAGES[action.type];
|
||||
if (msg) toast.success(msg);
|
||||
} else {
|
||||
toast.error(result.error || "작업에 실패했습니다.");
|
||||
}
|
||||
|
||||
// 성공 시 후속 액션 실행
|
||||
if (result.success && followUpActions?.length) {
|
||||
await executeFollowUpActions(followUpActions);
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[screenId]
|
||||
);
|
||||
|
||||
/**
|
||||
* 후속 액션 실행
|
||||
*/
|
||||
const executeFollowUpActions = useCallback(
|
||||
async (actions: FollowUpAction[]) => {
|
||||
for (const followUp of actions) {
|
||||
switch (followUp.type) {
|
||||
case "event":
|
||||
if (followUp.eventName) {
|
||||
publishRef.current(followUp.eventName, followUp.eventPayload);
|
||||
}
|
||||
break;
|
||||
|
||||
case "refresh":
|
||||
// 새로고침 이벤트 발행 (구독하는 컴포넌트가 refetch)
|
||||
publishRef.current("__pop_refresh__");
|
||||
break;
|
||||
|
||||
case "navigate":
|
||||
if (followUp.targetScreenId) {
|
||||
publishRef.current("__pop_navigate__", {
|
||||
screenId: followUp.targetScreenId,
|
||||
params: followUp.params,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "close-modal":
|
||||
publishRef.current("__pop_modal_close__");
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 외부에서 호출하는 실행 함수
|
||||
* confirm이 활성화되어 있으면 pendingConfirm에 저장하고 대기.
|
||||
* 비활성화이면 즉시 실행.
|
||||
*/
|
||||
const execute = useCallback(
|
||||
async (
|
||||
action: ButtonMainAction,
|
||||
options?: ExecuteActionOptions
|
||||
): Promise<ActionResult> => {
|
||||
const { rowData, fieldMapping, confirm, followUpActions } = options || {};
|
||||
|
||||
// 확인 다이얼로그 필요 시 대기
|
||||
if (confirm?.enabled) {
|
||||
setPendingConfirm({
|
||||
action,
|
||||
rowData,
|
||||
fieldMapping,
|
||||
confirm,
|
||||
followUpActions,
|
||||
});
|
||||
return { success: true }; // 대기 상태이므로 일단 success
|
||||
}
|
||||
|
||||
// 즉시 실행
|
||||
return runAction(action, rowData, fieldMapping, followUpActions);
|
||||
},
|
||||
[runAction]
|
||||
);
|
||||
|
||||
/**
|
||||
* 확인 다이얼로그에서 "확인" 클릭 시
|
||||
*/
|
||||
const confirmExecute = useCallback(async () => {
|
||||
if (!pendingConfirm) return;
|
||||
|
||||
const { action, rowData, fieldMapping, followUpActions } = pendingConfirm;
|
||||
setPendingConfirm(null);
|
||||
|
||||
await runAction(action, rowData, fieldMapping, followUpActions);
|
||||
}, [pendingConfirm, runAction]);
|
||||
|
||||
/**
|
||||
* 확인 다이얼로그에서 "취소" 클릭 시
|
||||
*/
|
||||
const cancelConfirm = useCallback(() => {
|
||||
setPendingConfirm(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
execute,
|
||||
isLoading,
|
||||
pendingConfirm,
|
||||
confirmExecute,
|
||||
cancelConfirm,
|
||||
} as const;
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* usePopEvent - POP 컴포넌트 간 이벤트 통신 훅
|
||||
*
|
||||
* 같은 화면(screenId) 안에서만 동작하는 이벤트 버스.
|
||||
* 다른 screenId 간에는 완전히 격리됨.
|
||||
*
|
||||
* 주요 기능:
|
||||
* - publish/subscribe: 일회성 이벤트 (거래처 선택됨, 저장 완료 등)
|
||||
* - getSharedData/setSharedData: 지속성 상태 (버튼 클릭 시 다른 컴포넌트 값 수집용)
|
||||
*
|
||||
* 사용 패턴:
|
||||
* ```typescript
|
||||
* const { publish, subscribe, getSharedData, setSharedData } = usePopEvent("S001");
|
||||
*
|
||||
* // 이벤트 구독 (반드시 useEffect 안에서, cleanup 필수)
|
||||
* useEffect(() => {
|
||||
* const unsub = subscribe("supplier-selected", (payload) => {
|
||||
* console.log(payload.supplierId);
|
||||
* });
|
||||
* return unsub;
|
||||
* }, []);
|
||||
*
|
||||
* // 이벤트 발행
|
||||
* publish("supplier-selected", { supplierId: "SUP-001" });
|
||||
*
|
||||
* // 공유 데이터 저장/조회
|
||||
* setSharedData("selectedSupplier", { id: "SUP-001" });
|
||||
* const supplier = getSharedData("selectedSupplier");
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
|
||||
/** 이벤트 콜백 함수 타입 */
|
||||
type EventCallback = (payload: unknown) => void;
|
||||
|
||||
/** 화면별 이벤트 리스너 맵: eventName -> Set<callback> */
|
||||
type ListenerMap = Map<string, Set<EventCallback>>;
|
||||
|
||||
/** 화면별 공유 데이터 맵: key -> value */
|
||||
type SharedDataMap = Map<string, unknown>;
|
||||
|
||||
// ===== 전역 저장소 (React 외부, 모듈 스코프) =====
|
||||
// SSR 환경에서 서버/클라이언트 간 공유 방지
|
||||
|
||||
/** screenId별 이벤트 리스너 저장소 */
|
||||
const screenBuses: Map<string, ListenerMap> =
|
||||
typeof window !== "undefined" ? new Map() : new Map();
|
||||
|
||||
/** screenId별 공유 데이터 저장소 */
|
||||
const sharedDataStore: Map<string, SharedDataMap> =
|
||||
typeof window !== "undefined" ? new Map() : new Map();
|
||||
|
||||
// ===== 내부 헬퍼 =====
|
||||
|
||||
/** 해당 screenId의 리스너 맵 가져오기 (없으면 생성) */
|
||||
function getListenerMap(screenId: string): ListenerMap {
|
||||
let map = screenBuses.get(screenId);
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
screenBuses.set(screenId, map);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 해당 screenId의 공유 데이터 맵 가져오기 (없으면 생성) */
|
||||
function getSharedMap(screenId: string): SharedDataMap {
|
||||
let map = sharedDataStore.get(screenId);
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
sharedDataStore.set(screenId, map);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ===== 외부 API: 화면 정리 =====
|
||||
|
||||
/**
|
||||
* 화면 언마운트 시 해당 screenId의 모든 리스너 + 공유 데이터 정리
|
||||
* 메모리 누수 방지용. 뷰어 또는 PopRenderer에서 화면 전환 시 호출.
|
||||
*/
|
||||
export function cleanupScreen(screenId: string): void {
|
||||
screenBuses.delete(screenId);
|
||||
sharedDataStore.delete(screenId);
|
||||
}
|
||||
|
||||
// ===== 메인 훅 =====
|
||||
|
||||
/**
|
||||
* POP 컴포넌트 간 이벤트 통신 훅
|
||||
*
|
||||
* @param screenId - 화면 ID (같은 screenId 안에서만 통신)
|
||||
* @returns publish, subscribe, getSharedData, setSharedData
|
||||
*/
|
||||
export function usePopEvent(screenId: string) {
|
||||
// screenId를 ref로 저장 (콜백 안정성)
|
||||
const screenIdRef = useRef(screenId);
|
||||
screenIdRef.current = screenId;
|
||||
|
||||
/**
|
||||
* 이벤트 발행
|
||||
* 해당 screenId + eventName에 등록된 모든 콜백에 payload 전달
|
||||
*/
|
||||
const publish = useCallback(
|
||||
(eventName: string, payload?: unknown): void => {
|
||||
const listeners = getListenerMap(screenIdRef.current);
|
||||
const callbacks = listeners.get(eventName);
|
||||
if (!callbacks || callbacks.size === 0) return;
|
||||
|
||||
// Set을 배열로 복사 후 순회 (순회 중 unsubscribe 안전)
|
||||
const callbackArray = Array.from(callbacks);
|
||||
for (const cb of callbackArray) {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
// 개별 콜백 에러가 다른 콜백 실행을 막지 않음
|
||||
console.error(
|
||||
`[usePopEvent] 콜백 에러 (screen: ${screenIdRef.current}, event: ${eventName}):`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 이벤트 구독
|
||||
*
|
||||
* 주의: 반드시 useEffect 안에서 호출하고, 반환값(unsubscribe)을 cleanup에서 호출할 것.
|
||||
*
|
||||
* @returns unsubscribe 함수
|
||||
*/
|
||||
const subscribe = useCallback(
|
||||
(eventName: string, callback: EventCallback): (() => void) => {
|
||||
const listeners = getListenerMap(screenIdRef.current);
|
||||
|
||||
let callbacks = listeners.get(eventName);
|
||||
if (!callbacks) {
|
||||
callbacks = new Set();
|
||||
listeners.set(eventName, callbacks);
|
||||
}
|
||||
callbacks.add(callback);
|
||||
|
||||
// unsubscribe 함수 반환
|
||||
const capturedScreenId = screenIdRef.current;
|
||||
return () => {
|
||||
const map = screenBuses.get(capturedScreenId);
|
||||
if (!map) return;
|
||||
const cbs = map.get(eventName);
|
||||
if (!cbs) return;
|
||||
cbs.delete(callback);
|
||||
// 빈 Set 정리
|
||||
if (cbs.size === 0) {
|
||||
map.delete(eventName);
|
||||
}
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 공유 데이터 조회
|
||||
* 다른 컴포넌트가 setSharedData로 저장한 값을 가져옴
|
||||
*/
|
||||
const getSharedData = useCallback(
|
||||
<T = unknown>(key: string): T | undefined => {
|
||||
const shared = sharedDataStore.get(screenIdRef.current);
|
||||
if (!shared) return undefined;
|
||||
return shared.get(key) as T | undefined;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 공유 데이터 저장
|
||||
* 같은 screenId의 다른 컴포넌트가 getSharedData로 읽을 수 있음
|
||||
*/
|
||||
const setSharedData = useCallback(
|
||||
(key: string, value: unknown): void => {
|
||||
const shared = getSharedMap(screenIdRef.current);
|
||||
shared.set(key, value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { publish, subscribe, getSharedData, setSharedData } as const;
|
||||
}
|
||||
|
|
@ -2,6 +2,24 @@
|
|||
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* 연결 메타 항목: 컴포넌트가 보내거나 받을 수 있는 데이터 슬롯
|
||||
*/
|
||||
export interface ConnectionMetaItem {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 연결 메타데이터: 디자이너가 연결 가능한 입출력 정의
|
||||
*/
|
||||
export interface ComponentConnectionMeta {
|
||||
sendable: ConnectionMetaItem[];
|
||||
receivable: ConnectionMetaItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 컴포넌트 정의 인터페이스
|
||||
*/
|
||||
|
|
@ -15,6 +33,7 @@ export interface PopComponentDefinition {
|
|||
configPanel?: React.ComponentType<any>;
|
||||
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
|
||||
defaultProps?: Record<string, any>;
|
||||
connectionMeta?: ComponentConnectionMeta;
|
||||
// POP 전용 속성
|
||||
touchOptimized?: boolean;
|
||||
minTouchArea?: number;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,34 @@ import { Button } from "@/components/ui/button";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { X } from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import {
|
||||
X,
|
||||
Check,
|
||||
Plus,
|
||||
Minus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Search,
|
||||
Save,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download,
|
||||
Upload,
|
||||
ExternalLink,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||
X, Check, Plus, Minus, Edit, Trash2, Search, Save, RefreshCw,
|
||||
AlertCircle, Info, Settings, ChevronDown, ChevronUp, ChevronRight,
|
||||
Copy, Download, Upload, ExternalLink,
|
||||
};
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -1559,7 +1585,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
switch (displayItem.type) {
|
||||
case "icon": {
|
||||
if (!displayItem.icon) return null;
|
||||
const IconComponent = (LucideIcons as any)[displayItem.icon];
|
||||
const IconComponent = LUCIDE_ICON_MAP[displayItem.icon];
|
||||
if (!IconComponent) return null;
|
||||
return <IconComponent key={displayItem.id} className="mr-1 inline-block h-3 w-3" style={inlineStyle} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,14 @@ export * from "./types";
|
|||
|
||||
// POP 컴포넌트 등록
|
||||
import "./pop-text";
|
||||
import "./pop-icon";
|
||||
import "./pop-dashboard";
|
||||
import "./pop-card-list";
|
||||
|
||||
import "./pop-button";
|
||||
import "./pop-string-list";
|
||||
import "./pop-search";
|
||||
|
||||
// 향후 추가될 컴포넌트들:
|
||||
// import "./pop-field";
|
||||
// import "./pop-button";
|
||||
// import "./pop-list";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,998 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||
import { usePopAction } from "@/hooks/pop/usePopAction";
|
||||
import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
|
||||
import {
|
||||
Save,
|
||||
Trash2,
|
||||
LogOut,
|
||||
Menu,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
Check,
|
||||
X,
|
||||
Edit,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Upload,
|
||||
Send,
|
||||
Copy,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ========================================
|
||||
// STEP 1: 타입 정의
|
||||
// ========================================
|
||||
|
||||
/** 메인 액션 타입 (5종) */
|
||||
export type ButtonActionType = "save" | "delete" | "api" | "modal" | "event";
|
||||
|
||||
/** 후속 액션 타입 (4종) */
|
||||
export type FollowUpActionType = "event" | "refresh" | "navigate" | "close-modal";
|
||||
|
||||
/** 버튼 variant (shadcn 기반 4종) */
|
||||
export type ButtonVariant = "default" | "secondary" | "outline" | "destructive";
|
||||
|
||||
/** 모달 열기 방식 */
|
||||
export type ModalMode = "dropdown" | "fullscreen" | "screen-ref";
|
||||
|
||||
/** 확인 다이얼로그 설정 */
|
||||
export interface ConfirmConfig {
|
||||
enabled: boolean;
|
||||
message?: string; // 빈값이면 기본 메시지
|
||||
}
|
||||
|
||||
/** 후속 액션 1건 */
|
||||
export interface FollowUpAction {
|
||||
type: FollowUpActionType;
|
||||
// event
|
||||
eventName?: string;
|
||||
eventPayload?: Record<string, unknown>;
|
||||
// navigate
|
||||
targetScreenId?: string;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 드롭다운 모달 메뉴 항목 */
|
||||
export interface ModalMenuItem {
|
||||
label: string;
|
||||
screenId?: string;
|
||||
action?: string; // 커스텀 이벤트명
|
||||
}
|
||||
|
||||
/** 메인 액션 설정 */
|
||||
export interface ButtonMainAction {
|
||||
type: ButtonActionType;
|
||||
// save/delete 공통
|
||||
targetTable?: string;
|
||||
// api
|
||||
apiEndpoint?: string;
|
||||
apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
// modal
|
||||
modalMode?: ModalMode;
|
||||
modalScreenId?: string;
|
||||
modalTitle?: string;
|
||||
modalItems?: ModalMenuItem[];
|
||||
// event
|
||||
eventName?: string;
|
||||
eventPayload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 프리셋 이름 */
|
||||
export type ButtonPreset =
|
||||
| "save"
|
||||
| "delete"
|
||||
| "logout"
|
||||
| "menu"
|
||||
| "modal-open"
|
||||
| "custom";
|
||||
|
||||
/** pop-button 전체 설정 */
|
||||
export interface PopButtonConfig {
|
||||
label: string;
|
||||
variant: ButtonVariant;
|
||||
icon?: string; // Lucide 아이콘 이름
|
||||
iconOnly?: boolean;
|
||||
preset: ButtonPreset;
|
||||
confirm?: ConfirmConfig;
|
||||
action: ButtonMainAction;
|
||||
followUpActions?: FollowUpAction[];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 상수
|
||||
// ========================================
|
||||
|
||||
/** 메인 액션 타입 라벨 */
|
||||
const ACTION_TYPE_LABELS: Record<ButtonActionType, string> = {
|
||||
save: "저장",
|
||||
delete: "삭제",
|
||||
api: "API 호출",
|
||||
modal: "모달 열기",
|
||||
event: "이벤트 발행",
|
||||
};
|
||||
|
||||
/** 후속 액션 타입 라벨 */
|
||||
const FOLLOWUP_TYPE_LABELS: Record<FollowUpActionType, string> = {
|
||||
event: "이벤트 발행",
|
||||
refresh: "새로고침",
|
||||
navigate: "화면 이동",
|
||||
"close-modal": "모달 닫기",
|
||||
};
|
||||
|
||||
/** variant 라벨 */
|
||||
const VARIANT_LABELS: Record<ButtonVariant, string> = {
|
||||
default: "기본 (Primary)",
|
||||
secondary: "보조 (Secondary)",
|
||||
outline: "외곽선 (Outline)",
|
||||
destructive: "위험 (Destructive)",
|
||||
};
|
||||
|
||||
/** 프리셋 라벨 */
|
||||
const PRESET_LABELS: Record<ButtonPreset, string> = {
|
||||
save: "저장",
|
||||
delete: "삭제",
|
||||
logout: "로그아웃",
|
||||
menu: "메뉴 (드롭다운)",
|
||||
"modal-open": "모달 열기",
|
||||
custom: "직접 설정",
|
||||
};
|
||||
|
||||
/** 모달 모드 라벨 */
|
||||
const MODAL_MODE_LABELS: Record<ModalMode, string> = {
|
||||
dropdown: "드롭다운",
|
||||
fullscreen: "전체 모달",
|
||||
"screen-ref": "화면 선택",
|
||||
};
|
||||
|
||||
/** API 메서드 라벨 */
|
||||
const API_METHOD_LABELS: Record<string, string> = {
|
||||
GET: "GET",
|
||||
POST: "POST",
|
||||
PUT: "PUT",
|
||||
DELETE: "DELETE",
|
||||
};
|
||||
|
||||
/** 주요 Lucide 아이콘 목록 (설정 패널용) */
|
||||
const ICON_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "none", label: "없음" },
|
||||
{ value: "Save", label: "저장 (Save)" },
|
||||
{ value: "Trash2", label: "삭제 (Trash)" },
|
||||
{ value: "LogOut", label: "로그아웃 (LogOut)" },
|
||||
{ value: "Menu", label: "메뉴 (Menu)" },
|
||||
{ value: "ExternalLink", label: "외부링크 (ExternalLink)" },
|
||||
{ value: "Plus", label: "추가 (Plus)" },
|
||||
{ value: "Check", label: "확인 (Check)" },
|
||||
{ value: "X", label: "취소 (X)" },
|
||||
{ value: "Edit", label: "수정 (Edit)" },
|
||||
{ value: "Search", label: "검색 (Search)" },
|
||||
{ value: "RefreshCw", label: "새로고침 (RefreshCw)" },
|
||||
{ value: "Download", label: "다운로드 (Download)" },
|
||||
{ value: "Upload", label: "업로드 (Upload)" },
|
||||
{ value: "Send", label: "전송 (Send)" },
|
||||
{ value: "Copy", label: "복사 (Copy)" },
|
||||
{ value: "Settings", label: "설정 (Settings)" },
|
||||
{ value: "ChevronDown", label: "아래 화살표 (ChevronDown)" },
|
||||
];
|
||||
|
||||
/** 프리셋별 기본 설정 */
|
||||
const PRESET_DEFAULTS: Record<ButtonPreset, Partial<PopButtonConfig>> = {
|
||||
save: {
|
||||
label: "저장",
|
||||
variant: "default",
|
||||
icon: "Save",
|
||||
confirm: { enabled: false },
|
||||
action: { type: "save" },
|
||||
},
|
||||
delete: {
|
||||
label: "삭제",
|
||||
variant: "destructive",
|
||||
icon: "Trash2",
|
||||
confirm: { enabled: true, message: "" },
|
||||
action: { type: "delete" },
|
||||
},
|
||||
logout: {
|
||||
label: "로그아웃",
|
||||
variant: "outline",
|
||||
icon: "LogOut",
|
||||
confirm: { enabled: true, message: "로그아웃 하시겠습니까?" },
|
||||
action: {
|
||||
type: "api",
|
||||
apiEndpoint: "/api/auth/logout",
|
||||
apiMethod: "POST",
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
label: "메뉴",
|
||||
variant: "secondary",
|
||||
icon: "Menu",
|
||||
confirm: { enabled: false },
|
||||
action: { type: "modal", modalMode: "dropdown" },
|
||||
},
|
||||
"modal-open": {
|
||||
label: "열기",
|
||||
variant: "outline",
|
||||
icon: "ExternalLink",
|
||||
confirm: { enabled: false },
|
||||
action: { type: "modal", modalMode: "fullscreen" },
|
||||
},
|
||||
custom: {
|
||||
label: "버튼",
|
||||
variant: "default",
|
||||
icon: "none",
|
||||
confirm: { enabled: false },
|
||||
action: { type: "save" },
|
||||
},
|
||||
};
|
||||
|
||||
/** 확인 다이얼로그 기본 메시지 (액션별) */
|
||||
const DEFAULT_CONFIRM_MESSAGES: Record<ButtonActionType, string> = {
|
||||
save: "저장하시겠습니까?",
|
||||
delete: "삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
api: "실행하시겠습니까?",
|
||||
modal: "열기하시겠습니까?",
|
||||
event: "실행하시겠습니까?",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 헬퍼 함수
|
||||
// ========================================
|
||||
|
||||
/** 섹션 구분선 */
|
||||
function SectionDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="pt-3 pb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-gray-300" />
|
||||
<span className="text-xs font-medium text-gray-500">{label}</span>
|
||||
<div className="h-px flex-1 bg-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */
|
||||
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||
Save, Trash2, LogOut, Menu, ExternalLink, Plus, Check, X,
|
||||
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
|
||||
};
|
||||
|
||||
/** Lucide 아이콘 동적 렌더링 */
|
||||
function DynamicLucideIcon({
|
||||
name,
|
||||
size = 16,
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const IconComponent = LUCIDE_ICON_MAP[name];
|
||||
if (!IconComponent) return null;
|
||||
return <IconComponent size={size} className={className} />;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// STEP 2: 메인 컴포넌트
|
||||
// ========================================
|
||||
|
||||
interface PopButtonComponentProps {
|
||||
config?: PopButtonConfig;
|
||||
label?: string;
|
||||
isDesignMode?: boolean;
|
||||
screenId?: string;
|
||||
}
|
||||
|
||||
export function PopButtonComponent({
|
||||
config,
|
||||
label,
|
||||
isDesignMode,
|
||||
screenId,
|
||||
}: PopButtonComponentProps) {
|
||||
// usePopAction 훅으로 액션 실행 통합
|
||||
const {
|
||||
execute,
|
||||
isLoading,
|
||||
pendingConfirm,
|
||||
confirmExecute,
|
||||
cancelConfirm,
|
||||
} = usePopAction(screenId || "");
|
||||
|
||||
// 확인 메시지 결정
|
||||
const getConfirmMessage = useCallback((): string => {
|
||||
if (pendingConfirm?.confirm?.message) return pendingConfirm.confirm.message;
|
||||
if (config?.confirm?.message) return config.confirm.message;
|
||||
return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"];
|
||||
}, [pendingConfirm?.confirm?.message, config?.confirm?.message, config?.action?.type]);
|
||||
|
||||
// 클릭 핸들러
|
||||
const handleClick = useCallback(async () => {
|
||||
// 디자인 모드: 실제 실행 안 함
|
||||
if (isDesignMode) {
|
||||
toast.info(
|
||||
`[디자인 모드] ${ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const action = config?.action;
|
||||
if (!action) return;
|
||||
|
||||
await execute(action, {
|
||||
confirm: config?.confirm,
|
||||
followUpActions: config?.followUpActions,
|
||||
});
|
||||
}, [isDesignMode, config?.action, config?.confirm, config?.followUpActions, execute]);
|
||||
|
||||
// 외형
|
||||
const buttonLabel = config?.label || label || "버튼";
|
||||
const variant = config?.variant || "default";
|
||||
const iconName = config?.icon || "";
|
||||
const isIconOnly = config?.iconOnly || false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"transition-transform active:scale-95",
|
||||
isIconOnly && "px-2"
|
||||
)}
|
||||
>
|
||||
{iconName && (
|
||||
<DynamicLucideIcon
|
||||
name={iconName}
|
||||
size={16}
|
||||
className={isIconOnly ? "" : "mr-1.5"}
|
||||
/>
|
||||
)}
|
||||
{!isIconOnly && <span>{buttonLabel}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 (usePopAction의 pendingConfirm 상태로 제어) */}
|
||||
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
|
||||
<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">
|
||||
{getConfirmMessage()}
|
||||
</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={confirmExecute}
|
||||
className={cn(
|
||||
"h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm",
|
||||
config?.action?.type === "delete" &&
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
)}
|
||||
>
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// STEP 3: 설정 패널
|
||||
// ========================================
|
||||
|
||||
interface PopButtonConfigPanelProps {
|
||||
config: PopButtonConfig;
|
||||
onUpdate: (config: PopButtonConfig) => void;
|
||||
}
|
||||
|
||||
export function PopButtonConfigPanel({
|
||||
config,
|
||||
onUpdate,
|
||||
}: PopButtonConfigPanelProps) {
|
||||
const isCustom = config?.preset === "custom";
|
||||
|
||||
// 프리셋 변경 핸들러
|
||||
const handlePresetChange = (preset: ButtonPreset) => {
|
||||
const defaults = PRESET_DEFAULTS[preset];
|
||||
onUpdate({
|
||||
...config,
|
||||
preset,
|
||||
label: defaults.label || config.label,
|
||||
variant: defaults.variant || config.variant,
|
||||
icon: defaults.icon ?? config.icon,
|
||||
confirm: defaults.confirm || config.confirm,
|
||||
action: (defaults.action as ButtonMainAction) || config.action,
|
||||
// 후속 액션은 프리셋 변경 시 유지
|
||||
});
|
||||
};
|
||||
|
||||
// 메인 액션 업데이트 헬퍼
|
||||
const updateAction = (updates: Partial<ButtonMainAction>) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
action: { ...config.action, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 overflow-y-auto pr-1 pb-32">
|
||||
{/* 프리셋 선택 */}
|
||||
<SectionDivider label="프리셋" />
|
||||
<Select
|
||||
value={config?.preset || "custom"}
|
||||
onValueChange={(v) => handlePresetChange(v as ButtonPreset)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PRESET_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isCustom && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
프리셋 변경 시 외형과 액션이 자동 설정됩니다
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 외형 설정 */}
|
||||
<SectionDivider label="외형" />
|
||||
<div className="space-y-2">
|
||||
{/* 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs">라벨</Label>
|
||||
<Input
|
||||
value={config?.label || ""}
|
||||
onChange={(e) => onUpdate({ ...config, label: e.target.value })}
|
||||
placeholder="버튼 텍스트"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* variant */}
|
||||
<div>
|
||||
<Label className="text-xs">스타일</Label>
|
||||
<Select
|
||||
value={config?.variant || "default"}
|
||||
onValueChange={(v) =>
|
||||
onUpdate({ ...config, variant: v as ButtonVariant })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(VARIANT_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 아이콘 */}
|
||||
<div>
|
||||
<Label className="text-xs">아이콘</Label>
|
||||
<Select
|
||||
value={config?.icon || "none"}
|
||||
onValueChange={(v) =>
|
||||
onUpdate({ ...config, icon: v === "none" ? "" : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ICON_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
{opt.value && (
|
||||
<DynamicLucideIcon name={opt.value} size={14} />
|
||||
)}
|
||||
<span>{opt.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 아이콘 전용 모드 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="iconOnly"
|
||||
checked={config?.iconOnly || false}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({ ...config, iconOnly: checked === true })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="iconOnly" className="text-xs">
|
||||
아이콘만 표시 (라벨 숨김)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 액션 */}
|
||||
<SectionDivider label="메인 액션" />
|
||||
<div className="space-y-2">
|
||||
{/* 액션 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">액션 유형</Label>
|
||||
<Select
|
||||
value={config?.action?.type || "save"}
|
||||
onValueChange={(v) =>
|
||||
updateAction({ type: v as ButtonActionType })
|
||||
}
|
||||
disabled={!isCustom}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(ACTION_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isCustom && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션별 추가 설정 */}
|
||||
<ActionDetailFields
|
||||
action={config?.action}
|
||||
onUpdate={updateAction}
|
||||
disabled={!isCustom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
<SectionDivider label="확인 메시지" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="confirmEnabled"
|
||||
checked={config?.confirm?.enabled || false}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({
|
||||
...config,
|
||||
confirm: {
|
||||
...config?.confirm,
|
||||
enabled: checked === true,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="confirmEnabled" className="text-xs">
|
||||
실행 전 확인 메시지 표시
|
||||
</Label>
|
||||
</div>
|
||||
{config?.confirm?.enabled && (
|
||||
<div>
|
||||
<Input
|
||||
value={config?.confirm?.message || ""}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
...config,
|
||||
confirm: {
|
||||
...config?.confirm,
|
||||
enabled: true,
|
||||
message: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="비워두면 기본 메시지 사용"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
기본:{" "}
|
||||
{DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 후속 액션 */}
|
||||
<SectionDivider label="후속 액션" />
|
||||
<FollowUpActionsEditor
|
||||
actions={config?.followUpActions || []}
|
||||
onUpdate={(actions) =>
|
||||
onUpdate({ ...config, followUpActions: actions })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 액션 세부 필드 (타입별)
|
||||
// ========================================
|
||||
|
||||
function ActionDetailFields({
|
||||
action,
|
||||
onUpdate,
|
||||
disabled,
|
||||
}: {
|
||||
action?: ButtonMainAction;
|
||||
onUpdate: (updates: Partial<ButtonMainAction>) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
// 디자이너 컨텍스트 (뷰어에서는 null)
|
||||
const designerCtx = usePopDesignerContext();
|
||||
const actionType = action?.type || "save";
|
||||
|
||||
switch (actionType) {
|
||||
case "save":
|
||||
case "delete":
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-xs">대상 테이블</Label>
|
||||
<Input
|
||||
value={action?.targetTable || ""}
|
||||
onChange={(e) => onUpdate({ targetTable: e.target.value })}
|
||||
placeholder="테이블명 입력"
|
||||
className="h-8 text-xs"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "api":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">엔드포인트</Label>
|
||||
<Input
|
||||
value={action?.apiEndpoint || ""}
|
||||
onChange={(e) => onUpdate({ apiEndpoint: e.target.value })}
|
||||
placeholder="/api/..."
|
||||
className="h-8 text-xs"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">HTTP 메서드</Label>
|
||||
<Select
|
||||
value={action?.apiMethod || "POST"}
|
||||
onValueChange={(v) =>
|
||||
onUpdate({
|
||||
apiMethod: v as "GET" | "POST" | "PUT" | "DELETE",
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(API_METHOD_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "modal":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">모달 방식</Label>
|
||||
<Select
|
||||
value={action?.modalMode || "fullscreen"}
|
||||
onValueChange={(v) =>
|
||||
onUpdate({ modalMode: v as ModalMode })
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(MODAL_MODE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{action?.modalMode === "screen-ref" && (
|
||||
<div>
|
||||
<Label className="text-xs">화면 ID</Label>
|
||||
<Input
|
||||
value={action?.modalScreenId || ""}
|
||||
onChange={(e) =>
|
||||
onUpdate({ modalScreenId: e.target.value })
|
||||
}
|
||||
placeholder="화면 ID"
|
||||
className="h-8 text-xs"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-xs">모달 제목</Label>
|
||||
<Input
|
||||
value={action?.modalTitle || ""}
|
||||
onChange={(e) => onUpdate({ modalTitle: e.target.value })}
|
||||
placeholder="모달 제목 (선택)"
|
||||
className="h-8 text-xs"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{/* 모달 캔버스 생성/열기 (fullscreen 모드 + 디자이너 내부) */}
|
||||
{action?.modalMode === "fullscreen" && designerCtx && (
|
||||
<div>
|
||||
{action?.modalScreenId ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-full text-xs"
|
||||
onClick={() => designerCtx.navigateToCanvas(action.modalScreenId!)}
|
||||
>
|
||||
모달 캔버스 열기
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-full text-xs"
|
||||
onClick={() => {
|
||||
const selectedId = designerCtx.selectedComponentId;
|
||||
if (!selectedId) return;
|
||||
const modalId = designerCtx.createModalCanvas(
|
||||
selectedId,
|
||||
action?.modalTitle || "새 모달"
|
||||
);
|
||||
onUpdate({ modalScreenId: modalId });
|
||||
}}
|
||||
>
|
||||
모달 캔버스 생성
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "event":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">이벤트명</Label>
|
||||
<Input
|
||||
value={action?.eventName || ""}
|
||||
onChange={(e) => onUpdate({ eventName: e.target.value })}
|
||||
placeholder="예: data-saved, item-selected"
|
||||
className="h-8 text-xs"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 후속 액션 편집기
|
||||
// ========================================
|
||||
|
||||
function FollowUpActionsEditor({
|
||||
actions,
|
||||
onUpdate,
|
||||
}: {
|
||||
actions: FollowUpAction[];
|
||||
onUpdate: (actions: FollowUpAction[]) => void;
|
||||
}) {
|
||||
// 추가
|
||||
const handleAdd = () => {
|
||||
onUpdate([...actions, { type: "event" }]);
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleRemove = (index: number) => {
|
||||
onUpdate(actions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 수정
|
||||
const handleUpdate = (index: number, updates: Partial<FollowUpAction>) => {
|
||||
const newActions = [...actions];
|
||||
newActions[index] = { ...newActions[index], ...updates };
|
||||
onUpdate(newActions);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{actions.length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
메인 액션 성공 후 순차 실행할 후속 동작
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actions.map((fa, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="space-y-1.5 rounded border p-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
후속 {idx + 1}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(idx)}
|
||||
className="h-5 px-1 text-[10px] text-destructive"
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 타입 선택 */}
|
||||
<Select
|
||||
value={fa.type}
|
||||
onValueChange={(v) =>
|
||||
handleUpdate(idx, { type: v as FollowUpActionType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FOLLOWUP_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 타입별 추가 입력 */}
|
||||
{fa.type === "event" && (
|
||||
<Input
|
||||
value={fa.eventName || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdate(idx, { eventName: e.target.value })
|
||||
}
|
||||
placeholder="이벤트명"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
)}
|
||||
{fa.type === "navigate" && (
|
||||
<Input
|
||||
value={fa.targetScreenId || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdate(idx, { targetScreenId: e.target.value })
|
||||
}
|
||||
placeholder="화면 ID"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="w-full h-7 text-xs"
|
||||
>
|
||||
후속 액션 추가
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// STEP 4: 미리보기 + 레지스트리 등록
|
||||
// ========================================
|
||||
|
||||
function PopButtonPreviewComponent({
|
||||
config,
|
||||
}: {
|
||||
config?: PopButtonConfig;
|
||||
}) {
|
||||
const buttonLabel = config?.label || "버튼";
|
||||
const variant = config?.variant || "default";
|
||||
const iconName = config?.icon || "";
|
||||
const isIconOnly = config?.iconOnly || false;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
||||
<Button
|
||||
variant={variant}
|
||||
className={cn(
|
||||
"pointer-events-none",
|
||||
isIconOnly && "px-2"
|
||||
)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{iconName && (
|
||||
<DynamicLucideIcon
|
||||
name={iconName}
|
||||
size={16}
|
||||
className={isIconOnly ? "" : "mr-1.5"}
|
||||
/>
|
||||
)}
|
||||
{!isIconOnly && <span>{buttonLabel}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 레지스트리 등록
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-button",
|
||||
name: "버튼",
|
||||
description: "액션 버튼 (저장/삭제/API/모달/이벤트)",
|
||||
category: "action",
|
||||
icon: "MousePointerClick",
|
||||
component: PopButtonComponent,
|
||||
configPanel: PopButtonConfigPanel,
|
||||
preview: PopButtonPreviewComponent,
|
||||
defaultProps: {
|
||||
label: "버튼",
|
||||
variant: "default",
|
||||
preset: "custom",
|
||||
confirm: { enabled: false },
|
||||
action: { type: "save" },
|
||||
} as PopButtonConfig,
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Delete } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
} from "@/components/ui/dialog";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import {
|
||||
PackageUnitModal,
|
||||
PACKAGE_UNITS,
|
||||
type PackageUnit,
|
||||
} from "./PackageUnitModal";
|
||||
|
||||
interface NumberInputModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
unit?: string;
|
||||
initialValue?: number;
|
||||
initialPackageUnit?: string;
|
||||
min?: number;
|
||||
maxValue?: number;
|
||||
onConfirm: (value: number, packageUnit?: string) => void;
|
||||
}
|
||||
|
||||
export function NumberInputModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
unit = "EA",
|
||||
initialValue = 0,
|
||||
initialPackageUnit,
|
||||
min = 0,
|
||||
maxValue = 999999,
|
||||
onConfirm,
|
||||
}: NumberInputModalProps) {
|
||||
const [displayValue, setDisplayValue] = useState("");
|
||||
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
|
||||
const [isPackageModalOpen, setIsPackageModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDisplayValue(initialValue > 0 ? String(initialValue) : "");
|
||||
setPackageUnit(initialPackageUnit);
|
||||
}
|
||||
}, [open, initialValue, initialPackageUnit]);
|
||||
|
||||
const handleNumberClick = (num: string) => {
|
||||
const newStr = displayValue + num;
|
||||
const numericValue = parseInt(newStr, 10);
|
||||
setDisplayValue(numericValue > maxValue ? String(maxValue) : newStr);
|
||||
};
|
||||
|
||||
const handleBackspace = () =>
|
||||
setDisplayValue((prev) => prev.slice(0, -1));
|
||||
const handleClear = () => setDisplayValue("");
|
||||
const handleMax = () => setDisplayValue(String(maxValue));
|
||||
|
||||
const handleConfirm = () => {
|
||||
const numericValue = parseInt(displayValue, 10) || 0;
|
||||
const finalValue = Math.max(min, Math.min(maxValue, numericValue));
|
||||
onConfirm(finalValue, packageUnit);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handlePackageUnitSelect = (selected: PackageUnit) => {
|
||||
setPackageUnit(selected);
|
||||
};
|
||||
|
||||
const matchedUnit = packageUnit
|
||||
? PACKAGE_UNITS.find((u) => u.value === packageUnit)
|
||||
: null;
|
||||
const packageUnitLabel = matchedUnit?.label ?? null;
|
||||
const packageUnitEmoji = matchedUnit?.emoji ?? "📦";
|
||||
|
||||
const displayText = displayValue
|
||||
? parseInt(displayValue, 10).toLocaleString()
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] w-full max-w-[95vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden border shadow-lg duration-200 sm:max-w-[360px] sm:rounded-lg"
|
||||
>
|
||||
{/* 파란 헤더 */}
|
||||
<div className="flex items-center justify-between bg-blue-500 px-4 py-3">
|
||||
<span className="rounded-full bg-blue-400/50 px-3 py-1 text-sm font-medium text-white">
|
||||
최대 {maxValue.toLocaleString()} {unit}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPackageModalOpen(true)}
|
||||
className="flex items-center gap-1 rounded-full bg-white/20 px-3 py-1.5 text-sm font-medium text-white hover:bg-white/30 active:bg-white/40"
|
||||
>
|
||||
{packageUnitEmoji} {packageUnitLabel ? `${packageUnitLabel} ✓` : "포장등록"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-4">
|
||||
{/* 숫자 표시 영역 */}
|
||||
<div className="flex min-h-[72px] items-center justify-end rounded-xl border-2 border-gray-200 bg-gray-50 px-4 py-4">
|
||||
{displayText ? (
|
||||
<span className="text-4xl font-bold tracking-tight text-gray-900">
|
||||
{displayText}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xl text-gray-300">0</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 텍스트 */}
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
수량을 입력하세요
|
||||
</p>
|
||||
|
||||
{/* 키패드 4x4 */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{/* 1행: 7 8 9 ← (주황) */}
|
||||
{["7", "8", "9"].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
||||
onClick={() => handleNumberClick(n)}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600 active:bg-amber-200"
|
||||
onClick={handleBackspace}
|
||||
>
|
||||
<Delete className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 2행: 4 5 6 C (주황) */}
|
||||
{["4", "5", "6"].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
||||
onClick={() => handleNumberClick(n)}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="h-14 rounded-2xl bg-amber-100 text-base font-bold text-amber-600 active:bg-amber-200"
|
||||
onClick={handleClear}
|
||||
>
|
||||
C
|
||||
</button>
|
||||
|
||||
{/* 3행: 1 2 3 MAX (파란) */}
|
||||
{["1", "2", "3"].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
||||
onClick={() => handleNumberClick(n)}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="h-14 rounded-2xl bg-blue-100 text-sm font-bold text-blue-600 active:bg-blue-200"
|
||||
onClick={handleMax}
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
|
||||
{/* 4행: 0 / 확인 (초록, 3칸) */}
|
||||
<button
|
||||
type="button"
|
||||
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
||||
onClick={() => handleNumberClick("0")}
|
||||
>
|
||||
0
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="col-span-3 h-14 rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
|
||||
{/* 포장 단위 선택 모달 */}
|
||||
<PackageUnitModal
|
||||
open={isPackageModalOpen}
|
||||
onOpenChange={setIsPackageModalOpen}
|
||||
onSelect={handlePackageUnitSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export const PACKAGE_UNITS = [
|
||||
{ value: "box", label: "박스", emoji: "📦" },
|
||||
{ value: "bag", label: "포대", emoji: "🛍️" },
|
||||
{ value: "pack", label: "팩", emoji: "📋" },
|
||||
{ value: "bundle", label: "묶음", emoji: "🔗" },
|
||||
{ value: "roll", label: "롤", emoji: "🧻" },
|
||||
{ value: "barrel", label: "통", emoji: "🪣" },
|
||||
] as const;
|
||||
|
||||
export type PackageUnit = (typeof PACKAGE_UNITS)[number]["value"];
|
||||
|
||||
interface PackageUnitModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (unit: PackageUnit) => void;
|
||||
}
|
||||
|
||||
export function PackageUnitModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: PackageUnitModalProps) {
|
||||
const handleSelect = (unit: PackageUnit) => {
|
||||
onSelect(unit);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="z-1050" />
|
||||
|
||||
<DialogPrimitive.Content
|
||||
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-1100 w-full max-w-[90vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-lg border shadow-lg duration-200 sm:max-w-[380px]"
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="border-b px-4 py-3 pr-12">
|
||||
<h2 className="text-base font-semibold">📦 포장 단위 선택</h2>
|
||||
</div>
|
||||
|
||||
{/* 3x2 그리드 */}
|
||||
<div className="grid grid-cols-3 gap-3 p-4">
|
||||
{PACKAGE_UNITS.map((unit) => (
|
||||
<button
|
||||
key={unit.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(unit.value as PackageUnit)}
|
||||
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>{unit.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* X 닫기 버튼 */}
|
||||
<DialogClose className="ring-offset-background focus:ring-ring absolute top-3 right-3 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* pop-card-list 디자인 모드 미리보기 컴포넌트 (V2)
|
||||
*
|
||||
* 디자이너 캔버스에서 표시되는 미리보기
|
||||
* 이미지 참조 기반 카드 구조 반영
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { LayoutGrid, Package } from "lucide-react";
|
||||
import type { PopCardListConfig } from "../types";
|
||||
import {
|
||||
CARD_SCROLL_DIRECTION_LABELS,
|
||||
CARD_SIZE_LABELS,
|
||||
DEFAULT_CARD_IMAGE,
|
||||
} from "../types";
|
||||
|
||||
interface PopCardListPreviewProps {
|
||||
config?: PopCardListConfig;
|
||||
}
|
||||
|
||||
export function PopCardListPreviewComponent({
|
||||
config,
|
||||
}: PopCardListPreviewProps) {
|
||||
const scrollDirection = config?.scrollDirection || "vertical";
|
||||
const cardSize = config?.cardSize || "medium";
|
||||
const dataSource = config?.dataSource;
|
||||
const template = config?.cardTemplate;
|
||||
|
||||
const hasTable = !!dataSource?.tableName;
|
||||
const hasHeader =
|
||||
!!template?.header?.codeField || !!template?.header?.titleField;
|
||||
const hasImage = template?.image?.enabled ?? true;
|
||||
const fieldCount = template?.body?.fields?.length || 0;
|
||||
|
||||
const sampleCardCount = 2;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-muted/30 p-3">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">카드 목록</span>
|
||||
</div>
|
||||
|
||||
{/* 설정 배지 */}
|
||||
<div className="flex gap-1">
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] text-primary">
|
||||
{CARD_SCROLL_DIRECTION_LABELS[scrollDirection]}
|
||||
</span>
|
||||
<span className="rounded bg-secondary px-1.5 py-0.5 text-[9px] text-secondary-foreground">
|
||||
{CARD_SIZE_LABELS[cardSize]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 미선택 시 안내 */}
|
||||
{!hasTable ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Package className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
데이터 소스를 설정하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 테이블 정보 */}
|
||||
<div className="mb-2 text-center">
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
{dataSource.tableName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 카드 미리보기 */}
|
||||
<div
|
||||
className={`flex-1 flex gap-2 ${
|
||||
scrollDirection === "vertical"
|
||||
? "flex-col"
|
||||
: "flex-row overflow-x-auto"
|
||||
}`}
|
||||
>
|
||||
{Array.from({ length: sampleCardCount }).map((_, idx) => (
|
||||
<PreviewCard
|
||||
key={idx}
|
||||
index={idx}
|
||||
hasHeader={hasHeader}
|
||||
hasImage={hasImage}
|
||||
fieldCount={fieldCount}
|
||||
cardSize={cardSize}
|
||||
scrollDirection={scrollDirection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 필드 정보 */}
|
||||
{fieldCount > 0 && (
|
||||
<div className="mt-2 text-center">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{fieldCount}개 필드 설정됨
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 미리보기 카드 컴포넌트 =====
|
||||
|
||||
function PreviewCard({
|
||||
index,
|
||||
hasHeader,
|
||||
hasImage,
|
||||
fieldCount,
|
||||
cardSize,
|
||||
scrollDirection,
|
||||
}: {
|
||||
index: number;
|
||||
hasHeader: boolean;
|
||||
hasImage: boolean;
|
||||
fieldCount: number;
|
||||
cardSize: string;
|
||||
scrollDirection: string;
|
||||
}) {
|
||||
const sizeClass =
|
||||
cardSize === "small"
|
||||
? "min-h-[60px]"
|
||||
: cardSize === "large"
|
||||
? "min-h-[100px]"
|
||||
: "min-h-[80px]";
|
||||
|
||||
const widthClass =
|
||||
scrollDirection === "vertical"
|
||||
? "w-full"
|
||||
: "min-w-[140px] flex-shrink-0";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border bg-card overflow-hidden ${sizeClass} ${widthClass}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
{hasHeader && (
|
||||
<div className="border-b bg-muted/30 px-2 py-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-8 rounded bg-muted-foreground/20" />
|
||||
<span className="h-2 w-12 rounded bg-muted-foreground/30" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="flex p-2 gap-2">
|
||||
{/* 이미지 */}
|
||||
{hasImage && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-10 w-10 rounded border bg-muted/30 flex items-center justify-center">
|
||||
<img
|
||||
src={DEFAULT_CARD_IMAGE}
|
||||
alt=""
|
||||
className="h-6 w-6 opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="flex-1 space-y-1">
|
||||
{fieldCount > 0 ? (
|
||||
Array.from({ length: Math.min(fieldCount, 3) }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<span className="h-1.5 w-6 rounded bg-muted-foreground/20" />
|
||||
<span className="h-1.5 w-10 rounded bg-muted-foreground/30" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<span className="text-[8px] text-muted-foreground">
|
||||
필드 추가
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* pop-card-list 컴포넌트 레지스트리 등록 진입점 (V2)
|
||||
*
|
||||
* 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨
|
||||
*/
|
||||
|
||||
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||
import { PopCardListComponent } from "./PopCardListComponent";
|
||||
import { PopCardListConfigPanel } from "./PopCardListConfig";
|
||||
import { PopCardListPreviewComponent } from "./PopCardListPreview";
|
||||
import type { PopCardListConfig } from "../types";
|
||||
import { DEFAULT_CARD_IMAGE } from "../types";
|
||||
|
||||
const defaultConfig: PopCardListConfig = {
|
||||
// 데이터 소스 (테이블 단위)
|
||||
dataSource: {
|
||||
tableName: "",
|
||||
},
|
||||
// 카드 템플릿
|
||||
cardTemplate: {
|
||||
header: {
|
||||
codeField: undefined,
|
||||
titleField: undefined,
|
||||
},
|
||||
image: {
|
||||
enabled: true,
|
||||
imageColumn: undefined,
|
||||
defaultImage: DEFAULT_CARD_IMAGE,
|
||||
},
|
||||
body: {
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
// 스크롤 방향
|
||||
scrollDirection: "vertical",
|
||||
cardSize: "medium",
|
||||
// 그리드 배치 (가로 x 세로)
|
||||
gridColumns: 3,
|
||||
gridRows: 2,
|
||||
// 담기 버튼 기본 설정
|
||||
cartAction: {
|
||||
navigateMode: "none",
|
||||
iconType: "lucide",
|
||||
iconValue: "ShoppingCart",
|
||||
label: "담기",
|
||||
cancelLabel: "취소",
|
||||
},
|
||||
};
|
||||
|
||||
// 레지스트리 등록
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-card-list",
|
||||
name: "카드 목록",
|
||||
description: "테이블 데이터를 카드 형태로 표시 (헤더 + 이미지 + 필드 목록)",
|
||||
category: "display",
|
||||
icon: "LayoutGrid",
|
||||
component: PopCardListComponent,
|
||||
configPanel: PopCardListConfigPanel,
|
||||
preview: PopCardListPreviewComponent,
|
||||
defaultProps: defaultConfig,
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
@ -0,0 +1,459 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* pop-dashboard 메인 컴포넌트 (뷰어용)
|
||||
*
|
||||
* 멀티 아이템 컨테이너: 여러 집계 아이템을 묶어서 다양한 표시 모드로 렌더링
|
||||
*
|
||||
* @INFRA-EXTRACT 대상:
|
||||
* - fetchAggregatedData 호출부 -> useDataSource로 교체 예정
|
||||
* - filter_changed 이벤트 수신 -> usePopEvent로 교체 예정
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type {
|
||||
PopDashboardConfig,
|
||||
DashboardItem,
|
||||
DashboardPage,
|
||||
} from "../types";
|
||||
import { fetchAggregatedData } from "./utils/dataFetcher";
|
||||
import {
|
||||
evaluateFormula,
|
||||
formatFormulaResult,
|
||||
} from "./utils/formula";
|
||||
|
||||
// 서브타입 아이템 컴포넌트
|
||||
import { KpiCardComponent } from "./items/KpiCard";
|
||||
import { ChartItemComponent } from "./items/ChartItem";
|
||||
import { GaugeItemComponent } from "./items/GaugeItem";
|
||||
import { StatCardComponent } from "./items/StatCard";
|
||||
|
||||
// 표시 모드 컴포넌트
|
||||
import { ArrowsModeComponent } from "./modes/ArrowsMode";
|
||||
import { AutoSlideModeComponent } from "./modes/AutoSlideMode";
|
||||
import { GridModeComponent } from "./modes/GridMode";
|
||||
import { ScrollModeComponent } from "./modes/ScrollMode";
|
||||
|
||||
// ===== 마이그레이션: 기존 config -> 페이지 기반 구조 변환 =====
|
||||
|
||||
/**
|
||||
* 기존 config를 페이지 기반 구조로 마이그레이션.
|
||||
* 런타임에서만 사용 (저장된 config 원본은 변경하지 않음).
|
||||
*
|
||||
* 시나리오1: displayMode="grid" (가장 오래된 형태)
|
||||
* 시나리오2: useGridLayout=true (직전 마이그레이션 결과)
|
||||
* 시나리오3: pages 이미 있음 (새 형태) -> 변환 불필요
|
||||
* 시나리오4: pages 없음 + grid 아님 -> 빈 pages (아이템 하나씩 슬라이드)
|
||||
*/
|
||||
export function migrateConfig(
|
||||
raw: Record<string, unknown>
|
||||
): PopDashboardConfig {
|
||||
const config = { ...raw } as PopDashboardConfig & Record<string, unknown>;
|
||||
|
||||
// pages가 이미 있으면 마이그레이션 불필요
|
||||
if (
|
||||
Array.isArray(config.pages) &&
|
||||
config.pages.length > 0
|
||||
) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// 시나리오1: displayMode="grid" / 시나리오2: useGridLayout=true
|
||||
const wasGrid =
|
||||
config.displayMode === ("grid" as string) ||
|
||||
(config as Record<string, unknown>).useGridLayout === true;
|
||||
|
||||
if (wasGrid) {
|
||||
const cols =
|
||||
((config as Record<string, unknown>).gridColumns as number) ?? 2;
|
||||
const rows =
|
||||
((config as Record<string, unknown>).gridRows as number) ?? 2;
|
||||
const cells =
|
||||
((config as Record<string, unknown>).gridCells as DashboardPage["gridCells"]) ?? [];
|
||||
|
||||
const page: DashboardPage = {
|
||||
id: "migrated-page-1",
|
||||
label: "페이지 1",
|
||||
gridColumns: cols,
|
||||
gridRows: rows,
|
||||
gridCells: cells,
|
||||
};
|
||||
|
||||
config.pages = [page];
|
||||
|
||||
// displayMode="grid" 보정
|
||||
if (config.displayMode === ("grid" as string)) {
|
||||
(config as Record<string, unknown>).displayMode = "arrows";
|
||||
}
|
||||
}
|
||||
|
||||
return config as PopDashboardConfig;
|
||||
}
|
||||
|
||||
// ===== 내부 타입 =====
|
||||
|
||||
interface ItemData {
|
||||
/** 단일 집계 값 */
|
||||
value: number;
|
||||
/** 데이터 행 (차트용) */
|
||||
rows: Record<string, unknown>[];
|
||||
/** 수식 결과 표시 문자열 */
|
||||
formulaDisplay: string | null;
|
||||
/** 에러 메시지 */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ===== 데이터 로딩 함수 =====
|
||||
|
||||
/** 단일 아이템의 데이터를 조회 */
|
||||
async function loadItemData(item: DashboardItem): Promise<ItemData> {
|
||||
try {
|
||||
// 수식 모드
|
||||
if (item.formula?.enabled && item.formula.values.length > 0) {
|
||||
// 각 값(A, B, ...)을 병렬 조회
|
||||
const results = await Promise.allSettled(
|
||||
item.formula.values.map((fv) => fetchAggregatedData(fv.dataSource))
|
||||
);
|
||||
|
||||
const valueMap: Record<string, number> = {};
|
||||
for (let i = 0; i < item.formula.values.length; i++) {
|
||||
const result = results[i];
|
||||
const fv = item.formula.values[i];
|
||||
valueMap[fv.id] =
|
||||
result.status === "fulfilled" ? result.value.value : 0;
|
||||
}
|
||||
|
||||
const calculatedValue = evaluateFormula(
|
||||
item.formula.expression,
|
||||
valueMap
|
||||
);
|
||||
const formulaDisplay = formatFormulaResult(item.formula, valueMap);
|
||||
|
||||
return {
|
||||
value: calculatedValue,
|
||||
rows: [],
|
||||
formulaDisplay,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 단일 집계 모드
|
||||
const result = await fetchAggregatedData(item.dataSource);
|
||||
if (result.error) {
|
||||
return { value: 0, rows: [], formulaDisplay: null, error: result.error };
|
||||
}
|
||||
|
||||
return {
|
||||
value: result.value,
|
||||
rows: result.rows ?? [],
|
||||
formulaDisplay: null,
|
||||
error: null,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "데이터 로딩 실패";
|
||||
return { value: 0, rows: [], formulaDisplay: null, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 메인 컴포넌트 =====
|
||||
|
||||
export function PopDashboardComponent({
|
||||
config,
|
||||
previewPageIndex,
|
||||
}: {
|
||||
config?: PopDashboardConfig;
|
||||
/** 디자이너 페이지 미리보기: 이 인덱스의 페이지만 단독 렌더링 (-1이면 기본 모드) */
|
||||
previewPageIndex?: number;
|
||||
}) {
|
||||
const [dataMap, setDataMap] = useState<Record<string, ItemData>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(300);
|
||||
|
||||
// 보이는 아이템만 필터링 (hooks 이전에 early return 불가하므로 빈 배열 허용)
|
||||
const visibleItems = Array.isArray(config?.items)
|
||||
? config.items.filter((item) => item.visible)
|
||||
: [];
|
||||
|
||||
// 컨테이너 크기 감지
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 아이템 ID 목록 문자열 키 (참조 안정성 보장 - 매 렌더마다 새 배열 참조 방지)
|
||||
const visibleItemIds = JSON.stringify(visibleItems.map((i) => i.id));
|
||||
|
||||
// 데이터 로딩 함수
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const fetchAllData = useCallback(async () => {
|
||||
if (!visibleItems.length) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// 모든 아이템 병렬 로딩 (하나 실패해도 나머지 표시)
|
||||
// @INFRA-EXTRACT: useDataSource로 교체 예정
|
||||
const results = await Promise.allSettled(
|
||||
visibleItems.map((item) => loadItemData(item))
|
||||
);
|
||||
|
||||
const newDataMap: Record<string, ItemData> = {};
|
||||
for (let i = 0; i < visibleItems.length; i++) {
|
||||
const result = results[i];
|
||||
newDataMap[visibleItems[i].id] =
|
||||
result.status === "fulfilled"
|
||||
? result.value
|
||||
: { value: 0, rows: [], formulaDisplay: null, error: "로딩 실패" };
|
||||
}
|
||||
|
||||
setDataMap(newDataMap);
|
||||
setLoading(false);
|
||||
}, [visibleItemIds]);
|
||||
|
||||
// 초기 로딩 + 주기적 새로고침
|
||||
useEffect(() => {
|
||||
fetchAllData();
|
||||
|
||||
// refreshInterval 적용 (첫 번째 아이템 기준, 최소 5초 강제)
|
||||
const rawRefreshSec = visibleItems[0]?.dataSource.refreshInterval;
|
||||
const refreshSec = rawRefreshSec && rawRefreshSec > 0
|
||||
? Math.max(5, rawRefreshSec)
|
||||
: 0;
|
||||
if (refreshSec > 0) {
|
||||
refreshTimerRef.current = setInterval(fetchAllData, refreshSec * 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (refreshTimerRef.current) {
|
||||
clearInterval(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
// visibleItemIds로 아이템 변경 감지 (visibleItems 객체 참조 대신 문자열 키 사용)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchAllData, visibleItemIds]);
|
||||
|
||||
// 빈 설정 (모든 hooks 이후에 early return)
|
||||
if (!config || !config.items?.length) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted/20">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
대시보드 아이템을 추가하세요
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 단일 아이템 렌더링
|
||||
const renderSingleItem = (item: DashboardItem) => {
|
||||
const itemData = dataMap[item.id];
|
||||
if (!itemData) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (itemData.error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="text-xs text-destructive">{itemData.error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (item.subType) {
|
||||
case "kpi-card":
|
||||
return (
|
||||
<KpiCardComponent
|
||||
item={item}
|
||||
data={itemData.value}
|
||||
formulaDisplay={itemData.formulaDisplay}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "gauge":
|
||||
return <GaugeItemComponent item={item} data={itemData.value} />;
|
||||
case "stat-card": {
|
||||
// StatCard: 카테고리별 건수 맵 구성 (필터 적용)
|
||||
const categoryData: Record<string, number> = {};
|
||||
if (item.statConfig?.categories) {
|
||||
for (const cat of item.statConfig.categories) {
|
||||
if (cat.filter.column && cat.filter.value !== undefined && cat.filter.value !== "") {
|
||||
// 카테고리 필터로 rows 필터링
|
||||
const filtered = itemData.rows.filter((row) => {
|
||||
const cellValue = String(row[cat.filter.column] ?? "");
|
||||
const filterValue = String(cat.filter.value ?? "");
|
||||
switch (cat.filter.operator) {
|
||||
case "=":
|
||||
return cellValue === filterValue;
|
||||
case "!=":
|
||||
return cellValue !== filterValue;
|
||||
case "like":
|
||||
return cellValue.toLowerCase().includes(filterValue.toLowerCase());
|
||||
default:
|
||||
return cellValue === filterValue;
|
||||
}
|
||||
});
|
||||
categoryData[cat.label] = filtered.length;
|
||||
} else {
|
||||
// 필터 미설정 시 전체 건수
|
||||
categoryData[cat.label] = itemData.rows.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<StatCardComponent item={item} categoryData={categoryData} />
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
미지원 타입: {item.subType}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (loading && !Object.keys(dataMap).length) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 마이그레이션: 기존 config를 페이지 기반으로 변환
|
||||
const migrated = migrateConfig(config as unknown as Record<string, unknown>);
|
||||
const pages = migrated.pages ?? [];
|
||||
const displayMode = migrated.displayMode;
|
||||
|
||||
// 페이지 하나를 GridModeComponent로 렌더링
|
||||
const renderPageContent = (page: DashboardPage) => {
|
||||
return (
|
||||
<GridModeComponent
|
||||
cells={page.gridCells}
|
||||
columns={page.gridColumns}
|
||||
rows={page.gridRows}
|
||||
gap={config.gap}
|
||||
containerWidth={containerWidth}
|
||||
renderItem={(itemId) => {
|
||||
const item = visibleItems.find((i) => i.id === itemId);
|
||||
if (!item) return null;
|
||||
return renderSingleItem(item);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 슬라이드 수: pages가 있으면 페이지 수, 없으면 아이템 수 (기존 동작)
|
||||
const slideCount = pages.length > 0 ? pages.length : visibleItems.length;
|
||||
|
||||
// 슬라이드 렌더 콜백: pages가 있으면 페이지 렌더, 없으면 단일 아이템
|
||||
const renderSlide = (index: number) => {
|
||||
if (pages.length > 0 && pages[index]) {
|
||||
return renderPageContent(pages[index]);
|
||||
}
|
||||
// fallback: 아이템 하나씩 (기존 동작 - pages 미설정 시)
|
||||
if (visibleItems[index]) {
|
||||
return renderSingleItem(visibleItems[index]);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 페이지 미리보기 모드: 특정 페이지만 단독 렌더링 (디자이너에서 사용)
|
||||
if (
|
||||
typeof previewPageIndex === "number" &&
|
||||
previewPageIndex >= 0 &&
|
||||
pages[previewPageIndex]
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={
|
||||
config.backgroundColor
|
||||
? { backgroundColor: config.backgroundColor }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{renderPageContent(pages[previewPageIndex])}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 표시 모드별 렌더링
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={
|
||||
config.backgroundColor
|
||||
? { backgroundColor: config.backgroundColor }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{displayMode === "arrows" && (
|
||||
<ArrowsModeComponent
|
||||
itemCount={slideCount}
|
||||
showIndicator={config.showIndicator}
|
||||
renderItem={renderSlide}
|
||||
/>
|
||||
)}
|
||||
|
||||
{displayMode === "auto-slide" && (
|
||||
<AutoSlideModeComponent
|
||||
itemCount={slideCount}
|
||||
interval={config.autoSlideInterval}
|
||||
resumeDelay={config.autoSlideResumeDelay}
|
||||
showIndicator={config.showIndicator}
|
||||
renderItem={renderSlide}
|
||||
/>
|
||||
)}
|
||||
|
||||
{displayMode === "scroll" && (
|
||||
<ScrollModeComponent
|
||||
itemCount={slideCount}
|
||||
showIndicator={config.showIndicator}
|
||||
renderItem={renderSlide}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* pop-dashboard 디자이너 미리보기 컴포넌트
|
||||
*
|
||||
* 실제 데이터 없이 더미 레이아웃으로 미리보기 표시
|
||||
* 디자이너가 설정 변경 시 즉시 미리보기 확인 가능
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react";
|
||||
import type { PopDashboardConfig, DashboardSubType } from "../types";
|
||||
import { migrateConfig } from "./PopDashboardComponent";
|
||||
|
||||
// ===== 서브타입별 아이콘 매핑 =====
|
||||
|
||||
const SUBTYPE_ICONS: Record<DashboardSubType, React.ReactNode> = {
|
||||
"kpi-card": <BarChart3 className="h-4 w-4" />,
|
||||
chart: <PieChart className="h-4 w-4" />,
|
||||
gauge: <Gauge className="h-4 w-4" />,
|
||||
"stat-card": <LayoutList className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const SUBTYPE_LABELS: Record<DashboardSubType, string> = {
|
||||
"kpi-card": "KPI",
|
||||
chart: "차트",
|
||||
gauge: "게이지",
|
||||
"stat-card": "통계",
|
||||
};
|
||||
|
||||
// ===== 모드 라벨 =====
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
arrows: "좌우 버튼",
|
||||
"auto-slide": "자동 슬라이드",
|
||||
scroll: "스크롤",
|
||||
};
|
||||
|
||||
// ===== 더미 아이템 프리뷰 =====
|
||||
|
||||
function DummyItemPreview({
|
||||
subType,
|
||||
label,
|
||||
}: {
|
||||
subType: DashboardSubType;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 rounded border border-dashed border-muted-foreground/30 bg-muted/20 p-2">
|
||||
<span className="text-muted-foreground">
|
||||
{SUBTYPE_ICONS[subType]}
|
||||
</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{label || SUBTYPE_LABELS[subType]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 메인 미리보기 =====
|
||||
|
||||
export function PopDashboardPreviewComponent({
|
||||
config,
|
||||
}: {
|
||||
config?: PopDashboardConfig;
|
||||
}) {
|
||||
// config가 빈 객체 {} 또는 items가 없는 경우 방어
|
||||
if (!config || !Array.isArray(config.items) || !config.items.length) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 overflow-hidden">
|
||||
<BarChart3 className="h-6 w-6 text-muted-foreground/50" />
|
||||
<span className="text-[10px] text-muted-foreground">대시보드</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleItems = config.items.filter((i) => i.visible);
|
||||
|
||||
// 마이그레이션 적용
|
||||
const migrated = migrateConfig(config as unknown as Record<string, unknown>);
|
||||
const pages = migrated.pages ?? [];
|
||||
const hasPages = pages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden p-1">
|
||||
{/* 모드 + 페이지 뱃지 */}
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<span className="rounded bg-primary/10 px-1 py-0.5 text-[8px] font-medium text-primary">
|
||||
{MODE_LABELS[migrated.displayMode] ?? migrated.displayMode}
|
||||
</span>
|
||||
{hasPages && (
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-[8px] font-medium text-muted-foreground">
|
||||
{pages.length}페이지
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[8px] text-muted-foreground">
|
||||
{visibleItems.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="min-h-0 flex-1">
|
||||
{hasPages ? (
|
||||
// 첫 번째 페이지 그리드 미리보기
|
||||
<div
|
||||
className="h-full w-full gap-1"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${pages[0].gridColumns}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${pages[0].gridRows}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{pages[0].gridCells.length > 0
|
||||
? pages[0].gridCells.map((cell) => {
|
||||
const item = visibleItems.find(
|
||||
(i) => i.id === cell.itemId
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
style={{
|
||||
gridColumn: cell.gridColumn,
|
||||
gridRow: cell.gridRow,
|
||||
}}
|
||||
>
|
||||
{item ? (
|
||||
<DummyItemPreview
|
||||
subType={item.subType}
|
||||
label={item.label}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full rounded border border-dashed border-muted-foreground/20" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: visibleItems.slice(0, 4).map((item) => (
|
||||
<DummyItemPreview
|
||||
key={item.id}
|
||||
subType={item.subType}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 페이지 미설정: 첫 번째 아이템만 크게 표시
|
||||
<div className="relative h-full">
|
||||
{visibleItems[0] && (
|
||||
<DummyItemPreview
|
||||
subType={visibleItems[0].subType}
|
||||
label={visibleItems[0].label}
|
||||
/>
|
||||
)}
|
||||
{visibleItems.length > 1 && (
|
||||
<div className="absolute bottom-1 right-1 rounded-full bg-primary/80 px-1.5 py-0.5 text-[8px] font-medium text-primary-foreground">
|
||||
+{visibleItems.length - 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* pop-dashboard 컴포넌트 레지스트리 등록 진입점
|
||||
*
|
||||
* 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨
|
||||
*/
|
||||
|
||||
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||
import { PopDashboardComponent } from "./PopDashboardComponent";
|
||||
import { PopDashboardConfigPanel } from "./PopDashboardConfig";
|
||||
import { PopDashboardPreviewComponent } from "./PopDashboardPreview";
|
||||
|
||||
// 레지스트리 등록
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-dashboard",
|
||||
name: "대시보드",
|
||||
description: "여러 집계 아이템을 묶어서 다양한 방식으로 보여줌",
|
||||
category: "display",
|
||||
icon: "BarChart3",
|
||||
component: PopDashboardComponent,
|
||||
configPanel: PopDashboardConfigPanel,
|
||||
preview: PopDashboardPreviewComponent,
|
||||
defaultProps: {
|
||||
items: [],
|
||||
pages: [],
|
||||
displayMode: "arrows",
|
||||
autoSlideInterval: 5,
|
||||
autoSlideResumeDelay: 3,
|
||||
showIndicator: true,
|
||||
gap: 8,
|
||||
},
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 차트 서브타입 컴포넌트
|
||||
*
|
||||
* Recharts 기반 막대/원형/라인 차트
|
||||
* 컨테이너 크기가 너무 작으면 "차트 표시 불가" 메시지
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import type { DashboardItem } from "../../types";
|
||||
import { TEXT_ALIGN_CLASSES } from "../../types";
|
||||
import { abbreviateNumber } from "../utils/formula";
|
||||
|
||||
// ===== Props =====
|
||||
|
||||
export interface ChartItemProps {
|
||||
item: DashboardItem;
|
||||
/** 차트에 표시할 데이터 행 */
|
||||
rows: Record<string, unknown>[];
|
||||
/** 컨테이너 너비 (px) - 최소 크기 판단용 */
|
||||
containerWidth: number;
|
||||
}
|
||||
|
||||
// ===== 기본 색상 팔레트 =====
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
"#6366f1", // indigo
|
||||
"#8b5cf6", // violet
|
||||
"#06b6d4", // cyan
|
||||
"#10b981", // emerald
|
||||
"#f59e0b", // amber
|
||||
"#ef4444", // rose
|
||||
"#ec4899", // pink
|
||||
"#14b8a6", // teal
|
||||
];
|
||||
|
||||
// ===== 최소 표시 크기 =====
|
||||
|
||||
const MIN_CHART_WIDTH = 120;
|
||||
|
||||
// ===== 메인 컴포넌트 =====
|
||||
|
||||
export function ChartItemComponent({
|
||||
item,
|
||||
rows,
|
||||
containerWidth,
|
||||
}: ChartItemProps) {
|
||||
const { chartConfig, visibility, itemStyle } = item;
|
||||
const chartType = chartConfig?.chartType ?? "bar";
|
||||
const colors = chartConfig?.colors?.length
|
||||
? chartConfig.colors
|
||||
: DEFAULT_COLORS;
|
||||
const xKey = chartConfig?.xAxisColumn ?? "name";
|
||||
const yKey = chartConfig?.yAxisColumn ?? "value";
|
||||
|
||||
// 라벨 정렬만 사용자 설정
|
||||
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
|
||||
|
||||
// 컨테이너가 너무 작으면 메시지 표시
|
||||
if (containerWidth < MIN_CHART_WIDTH) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-1">
|
||||
<span className="text-[10px] text-muted-foreground">차트</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 없음
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">데이터 없음</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// X축 라벨이 긴지 판정 (7자 이상이면 대각선)
|
||||
const hasLongLabels = rows.some(
|
||||
(r) => String(r[xKey] ?? "").length > 7
|
||||
);
|
||||
const xAxisTickProps = hasLongLabels
|
||||
? { fontSize: 10, angle: -45, textAnchor: "end" as const }
|
||||
: { fontSize: 10 };
|
||||
// 긴 라벨이 있으면 하단 여백 확보
|
||||
const chartMargin = hasLongLabels
|
||||
? { top: 5, right: 10, bottom: 40, left: 10 }
|
||||
: { top: 5, right: 10, bottom: 5, left: 10 };
|
||||
|
||||
return (
|
||||
<div className="@container flex h-full w-full flex-col p-2">
|
||||
{/* 라벨 - 사용자 정렬 적용 */}
|
||||
{visibility.showLabel && (
|
||||
<p className={`w-full mb-1 text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
|
||||
{item.label}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 차트 영역 */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{chartType === "bar" ? (
|
||||
<BarChart data={rows as Record<string, string | number>[]} margin={chartMargin}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tick={xAxisTickProps}
|
||||
hide={containerWidth < 200}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10 }}
|
||||
hide={containerWidth < 200}
|
||||
tickFormatter={(v: number) => abbreviateNumber(v)}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Bar dataKey={yKey} fill={colors[0]} radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
) : chartType === "line" ? (
|
||||
<LineChart data={rows as Record<string, string | number>[]} margin={chartMargin}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tick={xAxisTickProps}
|
||||
hide={containerWidth < 200}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10 }}
|
||||
hide={containerWidth < 200}
|
||||
tickFormatter={(v: number) => abbreviateNumber(v)}
|
||||
/>
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={yKey}
|
||||
stroke={colors[0]}
|
||||
strokeWidth={2}
|
||||
dot={containerWidth > 250}
|
||||
/>
|
||||
</LineChart>
|
||||
) : (
|
||||
/* pie - 카테고리명 + 값 라벨 표시 */
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={rows as Record<string, string | number>[]}
|
||||
dataKey={yKey}
|
||||
nameKey={xKey}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={containerWidth > 400 ? "70%" : "80%"}
|
||||
label={
|
||||
containerWidth > 250
|
||||
? ({ name, value, percent }: { name: string; value: number; percent: number }) =>
|
||||
`${name} ${abbreviateNumber(value)} (${(percent * 100).toFixed(0)}%)`
|
||||
: false
|
||||
}
|
||||
labelLine={containerWidth > 250}
|
||||
>
|
||||
{rows.map((_, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={colors[index % colors.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [abbreviateNumber(value), name]}
|
||||
/>
|
||||
{containerWidth > 300 && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
iconSize={10}
|
||||
/>
|
||||
)}
|
||||
</PieChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 게이지 서브타입 컴포넌트
|
||||
*
|
||||
* SVG 기반 반원형 게이지 (외부 라이브러리 불필요)
|
||||
* min/max/target/current 표시, 달성률 구간별 색상
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import type { DashboardItem, FontSize } from "../../types";
|
||||
import { TEXT_ALIGN_CLASSES } from "../../types";
|
||||
import { abbreviateNumber } from "../utils/formula";
|
||||
|
||||
/** FontSize -> SVG 직접 fontSize(px) 매핑 */
|
||||
const SVG_FONT_SIZE_MAP: Record<FontSize, number> = {
|
||||
xs: 14,
|
||||
sm: 18,
|
||||
base: 24,
|
||||
lg: 32,
|
||||
xl: 48,
|
||||
};
|
||||
|
||||
// ===== Props =====
|
||||
|
||||
export interface GaugeItemProps {
|
||||
item: DashboardItem;
|
||||
data: number | null;
|
||||
/** 동적 목표값 (targetDataSource로 조회된 값) */
|
||||
targetValue?: number | null;
|
||||
}
|
||||
|
||||
// ===== 게이지 색상 판정 =====
|
||||
|
||||
function getGaugeColor(
|
||||
percentage: number,
|
||||
ranges?: { min: number; max: number; color: string }[]
|
||||
): string {
|
||||
if (ranges?.length) {
|
||||
const match = ranges.find((r) => percentage >= r.min && percentage <= r.max);
|
||||
if (match) return match.color;
|
||||
}
|
||||
// 기본 색상 (달성률 기준)
|
||||
if (percentage >= 80) return "#10b981"; // emerald
|
||||
if (percentage >= 50) return "#f59e0b"; // amber
|
||||
return "#ef4444"; // rose
|
||||
}
|
||||
|
||||
// ===== 메인 컴포넌트 =====
|
||||
|
||||
export function GaugeItemComponent({
|
||||
item,
|
||||
data,
|
||||
targetValue,
|
||||
}: GaugeItemProps) {
|
||||
const { visibility, gaugeConfig, itemStyle } = item;
|
||||
const current = data ?? 0;
|
||||
const min = gaugeConfig?.min ?? 0;
|
||||
const max = gaugeConfig?.max ?? 100;
|
||||
const target = targetValue ?? gaugeConfig?.target ?? max;
|
||||
|
||||
// 라벨 정렬만 사용자 설정
|
||||
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
|
||||
|
||||
// SVG 내부 텍스트는 기본값 고정 (사용자 설정 연동 제거)
|
||||
const svgValueFontSize = SVG_FONT_SIZE_MAP["base"]; // 24
|
||||
const svgSubFontSize = SVG_FONT_SIZE_MAP["xs"]; // 14
|
||||
|
||||
// 달성률 계산 (0~100)
|
||||
const range = max - min;
|
||||
const percentage = range > 0 ? Math.min(100, Math.max(0, ((current - min) / range) * 100)) : 0;
|
||||
const gaugeColor = getGaugeColor(percentage, gaugeConfig?.colorRanges);
|
||||
|
||||
// SVG 반원 게이지 수치
|
||||
const cx = 100;
|
||||
const cy = 90;
|
||||
const radius = 70;
|
||||
// 반원: 180도 -> percentage에 비례한 각도
|
||||
const startAngle = Math.PI; // 180도 (왼쪽)
|
||||
const endAngle = Math.PI - (percentage / 100) * Math.PI; // 0도 (오른쪽) 방향
|
||||
|
||||
const startX = cx + radius * Math.cos(startAngle);
|
||||
const startY = cy - radius * Math.sin(startAngle);
|
||||
const endX = cx + radius * Math.cos(endAngle);
|
||||
const endY = cy - radius * Math.sin(endAngle);
|
||||
const largeArcFlag = percentage > 50 ? 1 : 0;
|
||||
|
||||
return (
|
||||
<div className="@container flex h-full w-full flex-col items-center justify-center p-2">
|
||||
{/* 라벨 - 사용자 정렬 적용 */}
|
||||
{visibility.showLabel && (
|
||||
<p className={`w-full shrink-0 truncate text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
|
||||
{item.label}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 게이지 SVG - 높이/너비 모두 반응형 */}
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center w-full">
|
||||
<svg
|
||||
viewBox="0 0 200 110"
|
||||
className="h-full w-auto max-w-full"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
{/* 배경 반원 (회색) */}
|
||||
<path
|
||||
d={`M ${cx - radius} ${cy} A ${radius} ${radius} 0 0 1 ${cx + radius} ${cy}`}
|
||||
fill="none"
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* 값 반원 (색상) */}
|
||||
{percentage > 0 && (
|
||||
<path
|
||||
d={`M ${startX} ${startY} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`}
|
||||
fill="none"
|
||||
stroke={gaugeColor}
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 중앙 텍스트 */}
|
||||
{visibility.showValue && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy - 10}
|
||||
textAnchor="middle"
|
||||
className="fill-foreground font-bold"
|
||||
fontSize={svgValueFontSize}
|
||||
>
|
||||
{abbreviateNumber(current)}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* 퍼센트 */}
|
||||
<text
|
||||
x={cx}
|
||||
y={cy + 10}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground"
|
||||
fontSize={svgSubFontSize}
|
||||
>
|
||||
{percentage.toFixed(1)}%
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 목표값 */}
|
||||
{visibility.showTarget && (
|
||||
<p className="shrink-0 text-xs text-muted-foreground">
|
||||
목표: {abbreviateNumber(target)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* KPI 카드 서브타입 컴포넌트
|
||||
*
|
||||
* 큰 숫자 + 단위 + 증감 표시
|
||||
* CSS Container Query로 반응형 내부 콘텐츠
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import type { DashboardItem } from "../../types";
|
||||
import { TEXT_ALIGN_CLASSES } from "../../types";
|
||||
import { abbreviateNumber } from "../utils/formula";
|
||||
|
||||
// ===== Props =====
|
||||
|
||||
export interface KpiCardProps {
|
||||
item: DashboardItem;
|
||||
data: number | null;
|
||||
/** 이전 기간 대비 증감 퍼센트 (선택) */
|
||||
trendValue?: number | null;
|
||||
/** 수식 결과 표시 문자열 (formula가 있을 때) */
|
||||
formulaDisplay?: string | null;
|
||||
}
|
||||
|
||||
// ===== 증감 표시 =====
|
||||
|
||||
function TrendIndicator({ value }: { value: number }) {
|
||||
const isPositive = value > 0;
|
||||
const isZero = value === 0;
|
||||
const color = isPositive
|
||||
? "text-emerald-600"
|
||||
: isZero
|
||||
? "text-muted-foreground"
|
||||
: "text-rose-600";
|
||||
const arrow = isPositive ? "↑" : isZero ? "→" : "↓";
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-0.5 text-xs font-medium ${color}`}>
|
||||
<span>{arrow}</span>
|
||||
<span>{Math.abs(value).toFixed(1)}%</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 색상 구간 판정 =====
|
||||
|
||||
function getColorForValue(
|
||||
value: number,
|
||||
ranges?: { min: number; max: number; color: string }[]
|
||||
): string | undefined {
|
||||
if (!ranges?.length) return undefined;
|
||||
const match = ranges.find((r) => value >= r.min && value <= r.max);
|
||||
return match?.color;
|
||||
}
|
||||
|
||||
// ===== 메인 컴포넌트 =====
|
||||
|
||||
export function KpiCardComponent({
|
||||
item,
|
||||
data,
|
||||
trendValue,
|
||||
formulaDisplay,
|
||||
}: KpiCardProps) {
|
||||
const { visibility, kpiConfig, itemStyle } = item;
|
||||
const displayValue = data ?? 0;
|
||||
const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges);
|
||||
|
||||
// 라벨 정렬만 사용자 설정, 나머지는 @container 반응형 자동
|
||||
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
|
||||
|
||||
return (
|
||||
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
|
||||
{/* 라벨 - 사용자 정렬 적용 */}
|
||||
{visibility.showLabel && (
|
||||
<p className={`w-full text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
|
||||
{item.label}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 메인 값 - @container 반응형 */}
|
||||
{visibility.showValue && (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className="text-xl font-bold @[200px]:text-3xl @[350px]:text-4xl @[500px]:text-5xl"
|
||||
style={valueColor ? { color: valueColor } : undefined}
|
||||
>
|
||||
{formulaDisplay ?? abbreviateNumber(displayValue)}
|
||||
</span>
|
||||
|
||||
{/* 단위 */}
|
||||
{visibility.showUnit && kpiConfig?.unit && (
|
||||
<span className="text-xs text-muted-foreground @[200px]:text-sm">
|
||||
{kpiConfig.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 증감율 */}
|
||||
{visibility.showTrend && trendValue != null && (
|
||||
<TrendIndicator value={trendValue} />
|
||||
)}
|
||||
|
||||
{/* 보조 라벨 (수식 표시 등) */}
|
||||
{visibility.showSubLabel && formulaDisplay && (
|
||||
<p className="text-xs text-muted-foreground @[200px]:text-sm">
|
||||
{item.formula?.values.map((v) => v.label).join(" / ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 통계 카드 서브타입 컴포넌트
|
||||
*
|
||||
* 상태별 건수 표시 (대기/진행/완료 등)
|
||||
* 각 카테고리별 색상 및 링크 지원
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import type { DashboardItem } from "../../types";
|
||||
import { TEXT_ALIGN_CLASSES } from "../../types";
|
||||
import { abbreviateNumber } from "../utils/formula";
|
||||
|
||||
// ===== Props =====
|
||||
|
||||
export interface StatCardProps {
|
||||
item: DashboardItem;
|
||||
/** 카테고리별 건수 맵 (카테고리 label -> 건수) */
|
||||
categoryData: Record<string, number>;
|
||||
}
|
||||
|
||||
// ===== 기본 색상 팔레트 =====
|
||||
|
||||
const DEFAULT_STAT_COLORS = [
|
||||
"#6366f1", // indigo
|
||||
"#f59e0b", // amber
|
||||
"#10b981", // emerald
|
||||
"#ef4444", // rose
|
||||
"#8b5cf6", // violet
|
||||
];
|
||||
|
||||
// ===== 메인 컴포넌트 =====
|
||||
|
||||
export function StatCardComponent({ item, categoryData }: StatCardProps) {
|
||||
const { visibility, statConfig, itemStyle } = item;
|
||||
const categories = statConfig?.categories ?? [];
|
||||
const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0);
|
||||
|
||||
// 라벨 정렬만 사용자 설정
|
||||
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
|
||||
|
||||
return (
|
||||
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
|
||||
{/* 라벨 - 사용자 정렬 적용 */}
|
||||
{visibility.showLabel && (
|
||||
<p className={`w-full mb-1 text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
|
||||
{item.label}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 총합 - @container 반응형 */}
|
||||
{visibility.showValue && (
|
||||
<p className="mb-2 text-lg font-bold @[200px]:text-2xl @[350px]:text-3xl">
|
||||
{abbreviateNumber(total)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 카테고리별 건수 */}
|
||||
<div className="flex flex-wrap gap-2 @[200px]:gap-3">
|
||||
{categories.map((cat, index) => {
|
||||
const count = categoryData[cat.label] ?? 0;
|
||||
const color =
|
||||
cat.color ?? DEFAULT_STAT_COLORS[index % DEFAULT_STAT_COLORS.length];
|
||||
|
||||
return (
|
||||
<div key={cat.label} className="flex items-center gap-1">
|
||||
{/* 색상 점 */}
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full @[200px]:h-2.5 @[200px]:w-2.5"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{/* 라벨 + 건수 */}
|
||||
<span className="text-[10px] text-muted-foreground @[150px]:text-xs">
|
||||
{cat.label}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium @[150px]:text-xs">
|
||||
{abbreviateNumber(count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 보조 라벨 (단위 등) */}
|
||||
{visibility.showSubLabel && (
|
||||
<p className="mt-1 text-[10px] text-muted-foreground @[150px]:text-xs">
|
||||
{visibility.showUnit && item.kpiConfig?.unit
|
||||
? `단위: ${item.kpiConfig.unit}`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 좌우 버튼 표시 모드
|
||||
*
|
||||
* 화살표 버튼으로 아이템을 한 장씩 넘기는 모드
|
||||
* 터치 최적화: 최소 44x44px 터치 영역
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
// ===== Props =====
|
||||
|
||||
export interface ArrowsModeProps {
|
||||
/** 총 아이템 수 */
|
||||
itemCount: number;
|
||||
/** 페이지 인디케이터 표시 여부 */
|
||||
showIndicator?: boolean;
|
||||
/** 현재 인덱스에 해당하는 아이템 렌더링 */
|
||||
renderItem: (index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
// ===== 메인 컴포넌트 =====
|
||||
|
||||
export function ArrowsModeComponent({
|
||||
itemCount,
|
||||
showIndicator = true,
|
||||
renderItem,
|
||||
}: ArrowsModeProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const goToPrev = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1));
|
||||
}, [itemCount]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0));
|
||||
}, [itemCount]);
|
||||
|
||||
if (itemCount === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">아이템 없음</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* 아이템 (전체 영역 사용) */}
|
||||
<div className="h-full w-full">
|
||||
{renderItem(currentIndex)}
|
||||
</div>
|
||||
|
||||
{/* 좌우 화살표 (콘텐츠 위에 겹침) */}
|
||||
{itemCount > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToPrev}
|
||||
className="absolute left-1 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-background/70 shadow-sm backdrop-blur-sm transition-all hover:bg-background/90 active:scale-95"
|
||||
aria-label="이전"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToNext}
|
||||
className="absolute right-1 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-background/70 shadow-sm backdrop-blur-sm transition-all hover:bg-background/90 active:scale-95"
|
||||
aria-label="다음"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 페이지 인디케이터 (콘텐츠 하단에 겹침) */}
|
||||
{showIndicator && itemCount > 1 && (
|
||||
<div className="absolute bottom-1 left-0 right-0 z-10 flex items-center justify-center gap-1.5">
|
||||
{Array.from({ length: itemCount }).map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => setCurrentIndex(i)}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
i === currentIndex
|
||||
? "w-4 bg-primary"
|
||||
: "w-1.5 bg-muted-foreground/30"
|
||||
}`}
|
||||
aria-label={`${i + 1}번째 아이템`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 자동 슬라이드 표시 모드
|
||||
*
|
||||
* 전광판처럼 자동 전환, 터치 시 멈춤, 일정 시간 후 재개
|
||||
* 컴포넌트 unmount 시 타이머 정리 필수
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
// ===== Props =====
|
||||
|
||||
export interface AutoSlideModeProps {
|
||||
/** 총 아이템 수 */
|
||||
itemCount: number;
|
||||
/** 자동 전환 간격 (초, 기본 5) */
|
||||
interval?: number;
|
||||
/** 터치 후 자동 재개까지 대기 시간 (초, 기본 3) */
|
||||
resumeDelay?: number;
|
||||
/** 페이지 인디케이터 표시 여부 */
|
||||
showIndicator?: boolean;
|
||||
/** 현재 인덱스에 해당하는 아이템 렌더링 */
|
||||
renderItem: (index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
// ===== 메인 컴포넌트 =====
|
||||
|
||||
export function AutoSlideModeComponent({
|
||||
itemCount,
|
||||
interval = 5,
|
||||
resumeDelay = 3,
|
||||
showIndicator = true,
|
||||
renderItem,
|
||||
}: AutoSlideModeProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const resumeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 타이머 정리 함수
|
||||
const clearTimers = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (resumeTimerRef.current) {
|
||||
clearTimeout(resumeTimerRef.current);
|
||||
resumeTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 자동 슬라이드 시작
|
||||
const startAutoSlide = useCallback(() => {
|
||||
clearTimers();
|
||||
if (itemCount <= 1) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % itemCount);
|
||||
}, interval * 1000);
|
||||
}, [itemCount, interval, clearTimers]);
|
||||
|
||||
// 터치/클릭으로 일시 정지
|
||||
const handlePause = useCallback(() => {
|
||||
setIsPaused(true);
|
||||
clearTimers();
|
||||
|
||||
// resumeDelay 후 자동 재개
|
||||
resumeTimerRef.current = setTimeout(() => {
|
||||
setIsPaused(false);
|
||||
startAutoSlide();
|
||||
}, resumeDelay * 1000);
|
||||
}, [resumeDelay, clearTimers, startAutoSlide]);
|
||||
|
||||
// 마운트 시 자동 슬라이드 시작, unmount 시 정리
|
||||
useEffect(() => {
|
||||
if (!isPaused) {
|
||||
startAutoSlide();
|
||||
}
|
||||
return clearTimers;
|
||||
}, [isPaused, startAutoSlide, clearTimers]);
|
||||
|
||||
if (itemCount === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">아이템 없음</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full"
|
||||
onClick={handlePause}
|
||||
onTouchStart={handlePause}
|
||||
role="presentation"
|
||||
>
|
||||
{/* 콘텐츠 (슬라이드 애니메이션) */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="flex h-full transition-transform duration-500 ease-in-out"
|
||||
style={{
|
||||
width: `${itemCount * 100}%`,
|
||||
transform: `translateX(-${currentIndex * (100 / itemCount)}%)`,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: itemCount }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-full"
|
||||
style={{ width: `${100 / itemCount}%` }}
|
||||
>
|
||||
{renderItem(i)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 인디케이터 (콘텐츠 하단에 겹침) */}
|
||||
{showIndicator && itemCount > 1 && (
|
||||
<div className="absolute bottom-1 left-0 right-0 z-10 flex items-center justify-center gap-1.5">
|
||||
{isPaused && (
|
||||
<span className="mr-2 text-[10px] text-muted-foreground/70">
|
||||
일시정지
|
||||
</span>
|
||||
)}
|
||||
{Array.from({ length: itemCount }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
i === currentIndex
|
||||
? "w-4 bg-primary"
|
||||
: "w-1.5 bg-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 그리드 표시 모드
|
||||
*
|
||||
* CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영)
|
||||
* 각 셀에 @container 적용하여 내부 아이템 반응형
|
||||
*
|
||||
* 반응형 자동 조정:
|
||||
* - containerWidth에 따라 열 수를 자동 축소
|
||||
* - 설정된 열 수가 최대값이고, 공간이 부족하면 줄어듦
|
||||
* - 셀당 최소 너비(MIN_CELL_WIDTH) 기준으로 판단
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import type { DashboardCell } from "../../types";
|
||||
|
||||
// ===== 상수 =====
|
||||
|
||||
/** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */
|
||||
const MIN_CELL_WIDTH = 80;
|
||||
|
||||
// ===== Props =====
|
||||
|
||||
export interface GridModeProps {
|
||||
/** 셀 배치 정보 */
|
||||
cells: DashboardCell[];
|
||||
/** 설정된 열 수 (최대값) */
|
||||
columns: number;
|
||||
/** 설정된 행 수 */
|
||||
rows: number;
|
||||
/** 아이템 간 간격 (px) */
|
||||
gap?: number;
|
||||
/** 컨테이너 너비 (px, 반응형 자동 조정용) */
|
||||
containerWidth?: number;
|
||||
/** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */
|
||||
renderItem: (itemId: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
// ===== 반응형 열 수 계산 =====
|
||||
|
||||
/**
|
||||
* 컨테이너 너비에 맞는 실제 열 수를 계산
|
||||
*
|
||||
* 설정된 columns가 최대값이고, 공간이 부족하면 축소.
|
||||
* gap도 고려하여 계산.
|
||||
*
|
||||
* 예: columns=3, containerWidth=400, gap=8, MIN_CELL_WIDTH=160
|
||||
* 사용 가능 너비 = 400 - (3-1)*8 = 384
|
||||
* 셀당 너비 = 384/3 = 128 < 160 -> 열 축소
|
||||
* columns=2: 사용 가능 = 400 - (2-1)*8 = 392, 셀당 = 196 >= 160 -> OK
|
||||
*/
|
||||
function computeResponsiveColumns(
|
||||
configColumns: number,
|
||||
containerWidth: number,
|
||||
gap: number
|
||||
): number {
|
||||
if (containerWidth <= 0) return configColumns;
|
||||
|
||||
for (let cols = configColumns; cols >= 1; cols--) {
|
||||
const totalGap = (cols - 1) * gap;
|
||||
const cellWidth = (containerWidth - totalGap) / cols;
|
||||
if (cellWidth >= MIN_CELL_WIDTH) return cols;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 열 수가 줄어들 때 셀 배치를 자동 재배열
|
||||
*
|
||||
* 원본 gridColumn/gridRow를 actualColumns에 맞게 재매핑
|
||||
* 셀이 원래 위치를 유지하려고 시도하되, 넘치면 아래로 이동
|
||||
*/
|
||||
function remapCells(
|
||||
cells: DashboardCell[],
|
||||
configColumns: number,
|
||||
actualColumns: number,
|
||||
configRows: number
|
||||
): { remappedCells: DashboardCell[]; actualRows: number } {
|
||||
// 열 수가 같으면 원본 그대로
|
||||
if (actualColumns >= configColumns) {
|
||||
return { remappedCells: cells, actualRows: configRows };
|
||||
}
|
||||
|
||||
// 셀을 원래 위치 순서대로 정렬 (행 우선)
|
||||
const sorted = [...cells].sort((a, b) => {
|
||||
const aRow = parseInt(a.gridRow.split(" / ")[0]) || 0;
|
||||
const bRow = parseInt(b.gridRow.split(" / ")[0]) || 0;
|
||||
if (aRow !== bRow) return aRow - bRow;
|
||||
const aCol = parseInt(a.gridColumn.split(" / ")[0]) || 0;
|
||||
const bCol = parseInt(b.gridColumn.split(" / ")[0]) || 0;
|
||||
return aCol - bCol;
|
||||
});
|
||||
|
||||
// 순서대로 새 위치에 배치
|
||||
let maxRow = 0;
|
||||
const remapped = sorted.map((cell, index) => {
|
||||
const newCol = (index % actualColumns) + 1;
|
||||
const newRow = Math.floor(index / actualColumns) + 1;
|
||||
maxRow = Math.max(maxRow, newRow);
|
||||
return {
|
||||
...cell,
|
||||
gridColumn: `${newCol} / ${newCol + 1}`,
|
||||
gridRow: `${newRow} / ${newRow + 1}`,
|
||||
};
|
||||
});
|
||||
|
||||
return { remappedCells: remapped, actualRows: maxRow };
|
||||
}
|
||||
|
||||
// ===== 메인 컴포넌트 =====
|
||||
|
||||
export function GridModeComponent({
|
||||
cells,
|
||||
columns,
|
||||
rows,
|
||||
gap = 8,
|
||||
containerWidth,
|
||||
renderItem,
|
||||
}: GridModeProps) {
|
||||
// 반응형 열 수 계산
|
||||
const actualColumns = useMemo(
|
||||
() =>
|
||||
containerWidth
|
||||
? computeResponsiveColumns(columns, containerWidth, gap)
|
||||
: columns,
|
||||
[columns, containerWidth, gap]
|
||||
);
|
||||
|
||||
// 열 수가 줄었으면 셀 재배열
|
||||
const { remappedCells, actualRows } = useMemo(
|
||||
() => remapCells(cells, columns, actualColumns, rows),
|
||||
[cells, columns, actualColumns, rows]
|
||||
);
|
||||
|
||||
if (!remappedCells.length) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">셀 없음</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${actualColumns}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${actualRows}, 1fr)`,
|
||||
gap: `${gap}px`,
|
||||
}}
|
||||
>
|
||||
{remappedCells.map((cell) => (
|
||||
<div
|
||||
key={cell.id}
|
||||
className="@container min-h-0 min-w-0 overflow-hidden rounded-md border border-border/50 bg-card"
|
||||
style={{
|
||||
gridColumn: cell.gridColumn,
|
||||
gridRow: cell.gridRow,
|
||||
}}
|
||||
>
|
||||
{cell.itemId ? (
|
||||
renderItem(cell.itemId)
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
빈 셀
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 스크롤 표시 모드
|
||||
*
|
||||
* 가로 스크롤 + CSS scroll-snap으로 아이템 단위 스냅
|
||||
* 터치 스와이프 네이티브 지원
|
||||
*/
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
|
||||
// ===== Props =====
|
||||
|
||||
export interface ScrollModeProps {
|
||||
/** 총 아이템 수 */
|
||||
itemCount: number;
|
||||
/** 페이지 인디케이터 표시 여부 */
|
||||
showIndicator?: boolean;
|
||||
/** 현재 인덱스에 해당하는 아이템 렌더링 */
|
||||
renderItem: (index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
// ===== 메인 컴포넌트 =====
|
||||
|
||||
export function ScrollModeComponent({
|
||||
itemCount,
|
||||
showIndicator = true,
|
||||
renderItem,
|
||||
}: ScrollModeProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
// 스크롤 위치로 현재 인덱스 계산
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !el.clientWidth) return;
|
||||
const index = Math.round(el.scrollLeft / el.clientWidth);
|
||||
setActiveIndex(Math.min(index, itemCount - 1));
|
||||
}, [itemCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
if (itemCount === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">아이템 없음</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* 스크롤 영역 */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex min-h-0 flex-1 snap-x snap-mandatory overflow-x-auto scrollbar-none"
|
||||
>
|
||||
{Array.from({ length: itemCount }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-full w-full shrink-0 snap-center"
|
||||
>
|
||||
{renderItem(i)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 페이지 인디케이터 */}
|
||||
{showIndicator && itemCount > 1 && (
|
||||
<div className="flex items-center justify-center gap-1.5 py-1">
|
||||
{Array.from({ length: itemCount }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
i === activeIndex
|
||||
? "w-4 bg-primary"
|
||||
: "w-1.5 bg-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
/**
|
||||
* pop-dashboard 데이터 페처
|
||||
*
|
||||
* @INFRA-EXTRACT: useDataSource 완성 후 이 파일 전체를 훅으로 교체 예정
|
||||
* 현재는 직접 API 호출. 공통 인프라 완성 후 useDataSource 훅으로 교체.
|
||||
*
|
||||
* 보안:
|
||||
* - SQL 인젝션 방지: 사용자 입력값 이스케이프 처리
|
||||
* - 멀티테넌시: autoFilter 자동 전달
|
||||
* - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용
|
||||
*/
|
||||
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import type { TableInfo } from "@/lib/api/tableManagement";
|
||||
import type { DataSourceConfig, DataSourceFilter } from "../../types";
|
||||
|
||||
// ===== 타입 re-export =====
|
||||
|
||||
export type { TableInfo };
|
||||
|
||||
// ===== 반환 타입 =====
|
||||
|
||||
export interface AggregatedResult {
|
||||
value: number;
|
||||
rows?: Record<string, unknown>[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
udtName: string;
|
||||
}
|
||||
|
||||
// ===== SQL 값 이스케이프 =====
|
||||
|
||||
/** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */
|
||||
function escapeSQL(value: unknown): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
||||
// 문자열: 작은따옴표 이스케이프
|
||||
const str = String(value).replace(/'/g, "''");
|
||||
return `'${str}'`;
|
||||
}
|
||||
|
||||
// ===== 설정 완료 여부 검증 =====
|
||||
|
||||
/**
|
||||
* DataSourceConfig의 필수값이 모두 채워졌는지 검증
|
||||
* 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는
|
||||
* SQL을 생성하지 않도록 사전 차단
|
||||
*
|
||||
* @returns null이면 유효, 문자열이면 미완료 사유
|
||||
*/
|
||||
function validateDataSourceConfig(config: DataSourceConfig): string | null {
|
||||
// 테이블명 필수
|
||||
if (!config.tableName || !config.tableName.trim()) {
|
||||
return "테이블이 선택되지 않았습니다";
|
||||
}
|
||||
|
||||
// 집계 함수가 설정되었으면 대상 컬럼도 필수
|
||||
// (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능)
|
||||
if (config.aggregation) {
|
||||
const aggType = config.aggregation.type?.toLowerCase();
|
||||
const aggCol = config.aggregation.column?.trim();
|
||||
if (aggType !== "count" && !aggCol) {
|
||||
return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`;
|
||||
}
|
||||
}
|
||||
|
||||
// 조인이 있으면 조인 조건 필수
|
||||
if (config.joins?.length) {
|
||||
for (const join of config.joins) {
|
||||
if (!join.targetTable?.trim()) {
|
||||
return "조인 대상 테이블이 선택되지 않았습니다";
|
||||
}
|
||||
if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
|
||||
return "조인 조건 컬럼이 설정되지 않았습니다";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===== 필터 조건 SQL 생성 =====
|
||||
|
||||
/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */
|
||||
function buildWhereClause(filters: DataSourceFilter[]): string {
|
||||
// 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어)
|
||||
const validFilters = filters.filter((f) => f.column?.trim());
|
||||
if (!validFilters.length) return "";
|
||||
|
||||
const conditions = validFilters.map((f) => {
|
||||
const col = sanitizeIdentifier(f.column);
|
||||
|
||||
switch (f.operator) {
|
||||
case "between": {
|
||||
const arr = Array.isArray(f.value) ? f.value : [f.value, f.value];
|
||||
return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`;
|
||||
}
|
||||
case "in": {
|
||||
const arr = Array.isArray(f.value) ? f.value : [f.value];
|
||||
const vals = arr.map(escapeSQL).join(", ");
|
||||
return `${col} IN (${vals})`;
|
||||
}
|
||||
case "like":
|
||||
return `${col} LIKE ${escapeSQL(f.value)}`;
|
||||
default:
|
||||
return `${col} ${f.operator} ${escapeSQL(f.value)}`;
|
||||
}
|
||||
});
|
||||
|
||||
return `WHERE ${conditions.join(" AND ")}`;
|
||||
}
|
||||
|
||||
// ===== 식별자 검증 (테이블명, 컬럼명) =====
|
||||
|
||||
/** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */
|
||||
function sanitizeIdentifier(name: string): string {
|
||||
// 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용
|
||||
return name.replace(/[^a-zA-Z0-9_.]/g, "");
|
||||
}
|
||||
|
||||
// ===== 집계 SQL 빌더 =====
|
||||
|
||||
/**
|
||||
* DataSourceConfig를 SELECT SQL로 변환
|
||||
*
|
||||
* @param config - 데이터 소스 설정
|
||||
* @returns SQL 문자열
|
||||
*/
|
||||
export function buildAggregationSQL(config: DataSourceConfig): string {
|
||||
const tableName = sanitizeIdentifier(config.tableName);
|
||||
|
||||
// SELECT 절
|
||||
let selectClause: string;
|
||||
if (config.aggregation) {
|
||||
const aggType = config.aggregation.type.toUpperCase();
|
||||
const aggCol = config.aggregation.column?.trim()
|
||||
? sanitizeIdentifier(config.aggregation.column)
|
||||
: "";
|
||||
|
||||
// COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수
|
||||
if (!aggCol) {
|
||||
selectClause = aggType === "COUNT"
|
||||
? "COUNT(*) as value"
|
||||
: `${aggType}(${tableName}.*) as value`;
|
||||
} else {
|
||||
selectClause = `${aggType}(${aggCol}) as value`;
|
||||
}
|
||||
|
||||
// GROUP BY가 있으면 해당 컬럼도 SELECT에 포함
|
||||
if (config.aggregation.groupBy?.length) {
|
||||
const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", ");
|
||||
selectClause = `${groupCols}, ${selectClause}`;
|
||||
}
|
||||
} else {
|
||||
selectClause = "*";
|
||||
}
|
||||
|
||||
// FROM 절 (조인 포함 - 조건이 완전한 조인만 적용)
|
||||
let fromClause = tableName;
|
||||
if (config.joins?.length) {
|
||||
for (const join of config.joins) {
|
||||
// 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어)
|
||||
if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
|
||||
continue;
|
||||
}
|
||||
const joinTable = sanitizeIdentifier(join.targetTable);
|
||||
const joinType = join.joinType.toUpperCase();
|
||||
const srcCol = sanitizeIdentifier(join.on.sourceColumn);
|
||||
const tgtCol = sanitizeIdentifier(join.on.targetColumn);
|
||||
fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`;
|
||||
}
|
||||
}
|
||||
|
||||
// WHERE 절
|
||||
const whereClause = config.filters?.length
|
||||
? buildWhereClause(config.filters)
|
||||
: "";
|
||||
|
||||
// GROUP BY 절
|
||||
let groupByClause = "";
|
||||
if (config.aggregation?.groupBy?.length) {
|
||||
groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`;
|
||||
}
|
||||
|
||||
// ORDER BY 절
|
||||
let orderByClause = "";
|
||||
if (config.sort?.length) {
|
||||
const sortCols = config.sort
|
||||
.map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`)
|
||||
.join(", ");
|
||||
orderByClause = `ORDER BY ${sortCols}`;
|
||||
}
|
||||
|
||||
// LIMIT 절
|
||||
const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : "";
|
||||
|
||||
return [
|
||||
`SELECT ${selectClause}`,
|
||||
`FROM ${fromClause}`,
|
||||
whereClause,
|
||||
groupByClause,
|
||||
orderByClause,
|
||||
limitClause,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// ===== 메인 데이터 페처 =====
|
||||
|
||||
/**
|
||||
* DataSourceConfig 기반으로 데이터를 조회하여 집계 결과 반환
|
||||
*
|
||||
* API 선택 전략:
|
||||
* 1. 집계 있음 -> buildAggregationSQL -> dashboardApi.executeQuery()
|
||||
* 2. 조인만 있음 (2개 테이블) -> dataApi.getJoinedData() (추후 지원)
|
||||
* 3. 단순 조회 -> dataApi.getTableData()
|
||||
*
|
||||
* @INFRA-EXTRACT: useDataSource 완성 후 이 함수를 훅으로 교체
|
||||
*/
|
||||
export async function fetchAggregatedData(
|
||||
config: DataSourceConfig
|
||||
): Promise<AggregatedResult> {
|
||||
try {
|
||||
// 설정 완료 여부 검증 (미완료 시 SQL 전송 차단)
|
||||
const validationError = validateDataSourceConfig(config);
|
||||
if (validationError) {
|
||||
return { value: 0, rows: [], error: validationError };
|
||||
}
|
||||
|
||||
// 집계 또는 조인이 있으면 SQL 직접 실행
|
||||
if (config.aggregation || (config.joins && config.joins.length > 0)) {
|
||||
const sql = buildAggregationSQL(config);
|
||||
|
||||
// API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백
|
||||
let queryResult: { columns: string[]; rows: any[] };
|
||||
try {
|
||||
// 1차: apiClient (axios 기반, 인증/세션 안정적)
|
||||
const response = await apiClient.post("/dashboards/execute-query", { query: sql });
|
||||
if (response.data?.success && response.data?.data) {
|
||||
queryResult = response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data?.message || "쿼리 실행 실패");
|
||||
}
|
||||
} catch {
|
||||
// 2차: dashboardApi (fetch 기반, 폴백)
|
||||
queryResult = await dashboardApi.executeQuery(sql);
|
||||
}
|
||||
|
||||
if (queryResult.rows.length === 0) {
|
||||
return { value: 0, rows: [] };
|
||||
}
|
||||
|
||||
// PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨
|
||||
// Recharts PieChart 등은 숫자 타입이 필수이므로 변환 처리
|
||||
const processedRows = queryResult.rows.map((row: Record<string, unknown>) => {
|
||||
const converted: Record<string, unknown> = { ...row };
|
||||
for (const key of Object.keys(converted)) {
|
||||
const val = converted[key];
|
||||
if (typeof val === "string" && val !== "" && !isNaN(Number(val))) {
|
||||
converted[key] = Number(val);
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
});
|
||||
|
||||
// 첫 번째 행의 value 컬럼 추출
|
||||
const firstRow = processedRows[0];
|
||||
const numericValue = parseFloat(String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0));
|
||||
|
||||
return {
|
||||
value: Number.isFinite(numericValue) ? numericValue : 0,
|
||||
rows: processedRows,
|
||||
};
|
||||
}
|
||||
|
||||
// 단순 조회
|
||||
const tableResult = await dataApi.getTableData(config.tableName, {
|
||||
page: 1,
|
||||
size: config.limit ?? 100,
|
||||
sortBy: config.sort?.[0]?.column,
|
||||
sortOrder: config.sort?.[0]?.direction,
|
||||
filters: config.filters?.reduce(
|
||||
(acc, f) => {
|
||||
acc[f.column] = f.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
),
|
||||
});
|
||||
|
||||
// 단순 조회 시에는 행 수를 value로 사용
|
||||
return {
|
||||
value: tableResult.total ?? tableResult.data.length,
|
||||
rows: tableResult.data,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "데이터 조회 실패";
|
||||
return { value: 0, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 설정 패널용 헬퍼 =====
|
||||
|
||||
/**
|
||||
* 테이블 목록 조회 (설정 패널 드롭다운용)
|
||||
* dashboardApi.getTableSchema()로 특정 테이블의 스키마를 가져오되,
|
||||
* 테이블 목록은 별도로 필요하므로 간단히 반환
|
||||
*/
|
||||
export async function fetchTableColumns(
|
||||
tableName: string
|
||||
): Promise<ColumnInfo[]> {
|
||||
// 1차: tableManagementApi (apiClient/axios 기반, 인증 안정적)
|
||||
try {
|
||||
const response = await tableManagementApi.getTableSchema(tableName);
|
||||
if (response.success && response.data) {
|
||||
const cols = Array.isArray(response.data) ? response.data : [];
|
||||
if (cols.length > 0) {
|
||||
return cols.map((col: any) => ({
|
||||
name: col.columnName || col.column_name || col.name,
|
||||
type: col.dataType || col.data_type || col.type || "unknown",
|
||||
udtName: col.dbType || col.udt_name || col.udtName || "unknown",
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// tableManagementApi 실패 시 dashboardApi로 폴백
|
||||
}
|
||||
|
||||
// 2차: dashboardApi (fetch 기반, 폴백)
|
||||
try {
|
||||
const schema = await dashboardApi.getTableSchema(tableName);
|
||||
return schema.columns.map((col) => ({
|
||||
name: col.name,
|
||||
type: col.type,
|
||||
udtName: col.udtName,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 목록 조회 (설정 패널 Combobox용)
|
||||
* tableManagementApi.getTableList() 래핑
|
||||
*
|
||||
* @INFRA-EXTRACT: useDataSource 완성 후 교체 예정
|
||||
*/
|
||||
export async function fetchTableList(): Promise<TableInfo[]> {
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* pop-dashboard 수식 파싱 및 평가 유틸리티
|
||||
*
|
||||
* 보안: eval()/new Function() 사용 금지. 직접 사칙연산 파서 구현.
|
||||
*/
|
||||
|
||||
import type { FormulaConfig, FormulaDisplayFormat } from "../../types";
|
||||
|
||||
// ===== 토큰 타입 =====
|
||||
|
||||
type TokenType = "number" | "variable" | "operator" | "lparen" | "rparen";
|
||||
|
||||
interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// ===== 토크나이저 =====
|
||||
|
||||
/** 수식 문자열을 토큰 배열로 분리 */
|
||||
function tokenize(expression: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
const expr = expression.replace(/\s+/g, "");
|
||||
|
||||
while (i < expr.length) {
|
||||
const ch = expr[i];
|
||||
|
||||
// 숫자 (정수, 소수)
|
||||
if (/\d/.test(ch) || (ch === "." && i + 1 < expr.length && /\d/.test(expr[i + 1]))) {
|
||||
let num = "";
|
||||
while (i < expr.length && (/\d/.test(expr[i]) || expr[i] === ".")) {
|
||||
num += expr[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: "number", value: num });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 변수 (A, B, C 등 알파벳)
|
||||
if (/[A-Za-z]/.test(ch)) {
|
||||
let varName = "";
|
||||
while (i < expr.length && /[A-Za-z0-9_]/.test(expr[i])) {
|
||||
varName += expr[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: "variable", value: varName });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 연산자
|
||||
if ("+-*/".includes(ch)) {
|
||||
tokens.push({ type: "operator", value: ch });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 괄호
|
||||
if (ch === "(") {
|
||||
tokens.push({ type: "lparen", value: "(" });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ")") {
|
||||
tokens.push({ type: "rparen", value: ")" });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 알 수 없는 문자는 건너뜀
|
||||
i++;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ===== 재귀 하강 파서 =====
|
||||
|
||||
/**
|
||||
* 사칙연산 수식을 안전하게 평가 (재귀 하강 파서)
|
||||
*
|
||||
* 문법:
|
||||
* expr = term (('+' | '-') term)*
|
||||
* term = factor (('*' | '/') factor)*
|
||||
* factor = NUMBER | VARIABLE | '(' expr ')'
|
||||
*
|
||||
* @param expression - 수식 문자열 (예: "A / B * 100")
|
||||
* @param values - 변수값 맵 (예: { A: 1234, B: 5678 })
|
||||
* @returns 계산 결과 (0으로 나누기 시 0 반환)
|
||||
*/
|
||||
export function evaluateFormula(
|
||||
expression: string,
|
||||
values: Record<string, number>
|
||||
): number {
|
||||
const tokens = tokenize(expression);
|
||||
let pos = 0;
|
||||
|
||||
function peek(): Token | undefined {
|
||||
return tokens[pos];
|
||||
}
|
||||
|
||||
function consume(): Token {
|
||||
return tokens[pos++];
|
||||
}
|
||||
|
||||
// factor = NUMBER | VARIABLE | '(' expr ')'
|
||||
function parseFactor(): number {
|
||||
const token = peek();
|
||||
if (!token) return 0;
|
||||
|
||||
if (token.type === "number") {
|
||||
consume();
|
||||
return parseFloat(token.value);
|
||||
}
|
||||
|
||||
if (token.type === "variable") {
|
||||
consume();
|
||||
return values[token.value] ?? 0;
|
||||
}
|
||||
|
||||
if (token.type === "lparen") {
|
||||
consume(); // '(' 소비
|
||||
const result = parseExpr();
|
||||
if (peek()?.type === "rparen") {
|
||||
consume(); // ')' 소비
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 예상치 못한 토큰
|
||||
consume();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// term = factor (('*' | '/') factor)*
|
||||
function parseTerm(): number {
|
||||
let result = parseFactor();
|
||||
while (peek()?.type === "operator" && (peek()!.value === "*" || peek()!.value === "/")) {
|
||||
const op = consume().value;
|
||||
const right = parseFactor();
|
||||
if (op === "*") {
|
||||
result *= right;
|
||||
} else {
|
||||
// 0으로 나누기 방지
|
||||
result = right === 0 ? 0 : result / right;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// expr = term (('+' | '-') term)*
|
||||
function parseExpr(): number {
|
||||
let result = parseTerm();
|
||||
while (peek()?.type === "operator" && (peek()!.value === "+" || peek()!.value === "-")) {
|
||||
const op = consume().value;
|
||||
const right = parseTerm();
|
||||
result = op === "+" ? result + right : result - right;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = parseExpr();
|
||||
return Number.isFinite(result) ? result : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 결과를 displayFormat에 맞게 포맷팅
|
||||
*
|
||||
* @param config - 수식 설정
|
||||
* @param values - 변수값 맵 (예: { A: 1234, B: 5678 })
|
||||
* @returns 포맷된 문자열
|
||||
*/
|
||||
export function formatFormulaResult(
|
||||
config: FormulaConfig,
|
||||
values: Record<string, number>
|
||||
): string {
|
||||
const formatMap: Record<FormulaDisplayFormat, () => string> = {
|
||||
value: () => {
|
||||
const result = evaluateFormula(config.expression, values);
|
||||
return formatNumber(result);
|
||||
},
|
||||
fraction: () => {
|
||||
// "1,234 / 5,678" 형태
|
||||
const ids = config.values.map((v) => v.id);
|
||||
if (ids.length >= 2) {
|
||||
return `${formatNumber(values[ids[0]] ?? 0)} / ${formatNumber(values[ids[1]] ?? 0)}`;
|
||||
}
|
||||
return formatNumber(evaluateFormula(config.expression, values));
|
||||
},
|
||||
percent: () => {
|
||||
const result = evaluateFormula(config.expression, values);
|
||||
return `${(result * 100).toFixed(1)}%`;
|
||||
},
|
||||
ratio: () => {
|
||||
// "1,234 : 5,678" 형태
|
||||
const ids = config.values.map((v) => v.id);
|
||||
if (ids.length >= 2) {
|
||||
return `${formatNumber(values[ids[0]] ?? 0)} : ${formatNumber(values[ids[1]] ?? 0)}`;
|
||||
}
|
||||
return formatNumber(evaluateFormula(config.expression, values));
|
||||
},
|
||||
};
|
||||
|
||||
return formatMap[config.displayFormat]();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식에 사용된 변수 ID가 모두 존재하는지 검증
|
||||
*
|
||||
* @param expression - 수식 문자열
|
||||
* @param availableIds - 사용 가능한 변수 ID 배열
|
||||
* @returns 유효 여부
|
||||
*/
|
||||
export function validateExpression(
|
||||
expression: string,
|
||||
availableIds: string[]
|
||||
): boolean {
|
||||
const tokens = tokenize(expression);
|
||||
const usedVars = tokens
|
||||
.filter((t) => t.type === "variable")
|
||||
.map((t) => t.value);
|
||||
|
||||
return usedVars.every((v) => availableIds.includes(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* 큰 숫자 축약 (Container Query 축소 시 사용)
|
||||
*
|
||||
* 1234 -> "1,234"
|
||||
* 12345 -> "1.2만"
|
||||
* 1234567 -> "123.5만"
|
||||
* 123456789 -> "1.2억"
|
||||
*/
|
||||
export function abbreviateNumber(value: number): string {
|
||||
const abs = Math.abs(value);
|
||||
const sign = value < 0 ? "-" : "";
|
||||
|
||||
if (abs >= 100_000_000) {
|
||||
return `${sign}${(abs / 100_000_000).toFixed(1)}억`;
|
||||
}
|
||||
if (abs >= 10_000) {
|
||||
return `${sign}${(abs / 10_000).toFixed(1)}만`;
|
||||
}
|
||||
return `${sign}${formatNumber(abs)}`;
|
||||
}
|
||||
|
||||
// ===== 내부 헬퍼 =====
|
||||
|
||||
/** 숫자를 천 단위 콤마 포맷 */
|
||||
function formatNumber(value: number): string {
|
||||
if (Number.isInteger(value)) {
|
||||
return value.toLocaleString("ko-KR");
|
||||
}
|
||||
// 소수점 이하 최대 2자리
|
||||
return value.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,990 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||
import { GridMode } from "@/components/pop/designer/types/pop-layout";
|
||||
import {
|
||||
Home,
|
||||
ArrowLeft,
|
||||
Settings,
|
||||
Search,
|
||||
Plus,
|
||||
Check,
|
||||
X as XIcon,
|
||||
Edit,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||
Home, ArrowLeft, Settings, Search, Plus, Check, X: XIcon,
|
||||
Edit, Trash2, RefreshCw,
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 타입 정의
|
||||
// ========================================
|
||||
export type IconType = "quick" | "emoji" | "image";
|
||||
export type IconSizeMode = "auto" | "fixed";
|
||||
export type LabelPosition = "bottom" | "right" | "none";
|
||||
export type NavigateMode = "none" | "screen" | "url" | "back";
|
||||
|
||||
export interface IconSizeByMode {
|
||||
mobile_portrait: number;
|
||||
mobile_landscape: number;
|
||||
tablet_portrait: number;
|
||||
tablet_landscape: number;
|
||||
}
|
||||
|
||||
export interface GradientConfig {
|
||||
from: string;
|
||||
to: string;
|
||||
direction?: "to-b" | "to-r" | "to-br";
|
||||
}
|
||||
|
||||
export interface ImageConfig {
|
||||
fileObjid?: number;
|
||||
imageUrl?: string;
|
||||
// 임시 저장용 (브라우저 캐시)
|
||||
tempDataUrl?: string;
|
||||
tempFileName?: string;
|
||||
}
|
||||
|
||||
export interface PopIconAction {
|
||||
type: "navigate";
|
||||
navigate: {
|
||||
mode: NavigateMode;
|
||||
screenId?: string;
|
||||
url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface QuickSelectItem {
|
||||
type: "lucide" | "emoji";
|
||||
value: string;
|
||||
label: string;
|
||||
gradient: GradientConfig;
|
||||
}
|
||||
|
||||
export interface PopIconConfig {
|
||||
iconType: IconType;
|
||||
// 빠른 선택용
|
||||
quickSelectType?: "lucide" | "emoji";
|
||||
quickSelectValue?: string;
|
||||
// 이미지용
|
||||
imageConfig?: ImageConfig;
|
||||
imageScale?: number;
|
||||
// 공통
|
||||
label?: string;
|
||||
labelPosition?: LabelPosition;
|
||||
labelColor?: string;
|
||||
labelFontSize?: number;
|
||||
backgroundColor?: string;
|
||||
gradient?: GradientConfig;
|
||||
borderRadiusPercent?: number;
|
||||
sizeMode: IconSizeMode;
|
||||
fixedSize?: number;
|
||||
sizeByMode?: IconSizeByMode;
|
||||
action: PopIconAction;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 상수
|
||||
// ========================================
|
||||
export const ICON_TYPE_LABELS: Record<IconType, string> = {
|
||||
quick: "빠른 선택",
|
||||
emoji: "이모지 직접 입력",
|
||||
image: "이미지",
|
||||
};
|
||||
|
||||
// 섹션 구분선 컴포넌트
|
||||
function SectionDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="pt-3 pb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-gray-300" />
|
||||
<span className="text-xs font-medium text-gray-500">{label}</span>
|
||||
<div className="h-px flex-1 bg-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const NAVIGATE_MODE_LABELS: Record<NavigateMode, string> = {
|
||||
none: "없음",
|
||||
screen: "POP 화면",
|
||||
url: "외부 URL",
|
||||
back: "뒤로가기",
|
||||
};
|
||||
|
||||
export const LABEL_POSITION_LABELS: Record<LabelPosition, string> = {
|
||||
bottom: "아래",
|
||||
right: "오른쪽",
|
||||
none: "없음",
|
||||
};
|
||||
|
||||
export const DEFAULT_ICON_SIZE_BY_MODE: IconSizeByMode = {
|
||||
mobile_portrait: 48,
|
||||
mobile_landscape: 56,
|
||||
tablet_portrait: 64,
|
||||
tablet_landscape: 72,
|
||||
};
|
||||
|
||||
// 빠른 선택 아이템 (Lucide 10개 + 이모지)
|
||||
export const QUICK_SELECT_ITEMS: QuickSelectItem[] = [
|
||||
// 기본 아이콘 (Lucide) - 10개
|
||||
{ type: "lucide", value: "Home", label: "홈", gradient: { from: "#0984e3", to: "#0774c4" } },
|
||||
{ type: "lucide", value: "ArrowLeft", label: "뒤로", gradient: { from: "#636e72", to: "#525d61" } },
|
||||
{ type: "lucide", value: "Settings", label: "설정", gradient: { from: "#636e72", to: "#525d61" } },
|
||||
{ type: "lucide", value: "Search", label: "검색", gradient: { from: "#fdcb6e", to: "#f9b93b" } },
|
||||
{ type: "lucide", value: "Plus", label: "추가", gradient: { from: "#00b894", to: "#00a86b" } },
|
||||
{ type: "lucide", value: "Check", label: "확인", gradient: { from: "#00b894", to: "#00a86b" } },
|
||||
{ type: "lucide", value: "X", label: "취소", gradient: { from: "#e17055", to: "#d35845" } },
|
||||
{ type: "lucide", value: "Edit", label: "수정", gradient: { from: "#0984e3", to: "#0774c4" } },
|
||||
{ type: "lucide", value: "Trash2", label: "삭제", gradient: { from: "#e17055", to: "#d35845" } },
|
||||
{ type: "lucide", value: "RefreshCw", label: "새로고침", gradient: { from: "#4ecdc4", to: "#26a69a" } },
|
||||
// 이모지
|
||||
{ type: "emoji", value: "📋", label: "작업지시", gradient: { from: "#ff6b6b", to: "#ee5a5a" } },
|
||||
{ type: "emoji", value: "📊", label: "생산실적", gradient: { from: "#4ecdc4", to: "#26a69a" } },
|
||||
{ type: "emoji", value: "📦", label: "입고", gradient: { from: "#00b894", to: "#00a86b" } },
|
||||
{ type: "emoji", value: "🚚", label: "출고", gradient: { from: "#0984e3", to: "#0774c4" } },
|
||||
{ type: "emoji", value: "📈", label: "재고현황", gradient: { from: "#6c5ce7", to: "#5b4cdb" } },
|
||||
{ type: "emoji", value: "🔍", label: "품질검사", gradient: { from: "#fdcb6e", to: "#f9b93b" } },
|
||||
{ type: "emoji", value: "⚠️", label: "불량관리", gradient: { from: "#e17055", to: "#d35845" } },
|
||||
{ type: "emoji", value: "⚙️", label: "설비관리", gradient: { from: "#636e72", to: "#525d61" } },
|
||||
{ type: "emoji", value: "🦺", label: "안전관리", gradient: { from: "#f39c12", to: "#e67e22" } },
|
||||
{ type: "emoji", value: "🏭", label: "외주", gradient: { from: "#6c5ce7", to: "#5b4cdb" } },
|
||||
{ type: "emoji", value: "↩️", label: "반품", gradient: { from: "#e17055", to: "#d35845" } },
|
||||
{ type: "emoji", value: "🤝", label: "사급자재", gradient: { from: "#fdcb6e", to: "#f9b93b" } },
|
||||
{ type: "emoji", value: "🔄", label: "교환", gradient: { from: "#4ecdc4", to: "#26a69a" } },
|
||||
{ type: "emoji", value: "📍", label: "재고이동", gradient: { from: "#4ecdc4", to: "#26a69a" } },
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// 헬퍼 함수
|
||||
// ========================================
|
||||
function getIconSizeForMode(config: PopIconConfig | undefined, gridMode: GridMode): number {
|
||||
if (!config) return DEFAULT_ICON_SIZE_BY_MODE[gridMode];
|
||||
if (config.sizeMode === "fixed" && config.fixedSize) {
|
||||
return config.fixedSize;
|
||||
}
|
||||
const sizes = config.sizeByMode || DEFAULT_ICON_SIZE_BY_MODE;
|
||||
return sizes[gridMode];
|
||||
}
|
||||
|
||||
function buildGradientStyle(gradient?: GradientConfig): React.CSSProperties {
|
||||
if (!gradient) return {};
|
||||
const direction = gradient.direction || "to-b";
|
||||
const dirMap: Record<string, string> = {
|
||||
"to-b": "to bottom",
|
||||
"to-r": "to right",
|
||||
"to-br": "to bottom right"
|
||||
};
|
||||
return {
|
||||
background: `linear-gradient(${dirMap[direction]}, ${gradient.from}, ${gradient.to})`,
|
||||
};
|
||||
}
|
||||
|
||||
function getImageUrl(imageConfig?: ImageConfig): string | undefined {
|
||||
if (!imageConfig) return undefined;
|
||||
// 임시 저장된 이미지 우선
|
||||
if (imageConfig.tempDataUrl) return imageConfig.tempDataUrl;
|
||||
if (imageConfig.fileObjid) return `/api/files/preview/${imageConfig.fileObjid}`;
|
||||
return imageConfig.imageUrl;
|
||||
}
|
||||
|
||||
// Lucide 아이콘 동적 렌더링
|
||||
function DynamicLucideIcon({ name, size, className }: { name: string; size: number; className?: string }) {
|
||||
const IconComponent = LUCIDE_ICON_MAP[name];
|
||||
if (!IconComponent) return null;
|
||||
return <IconComponent size={size} className={className} />;
|
||||
}
|
||||
|
||||
// screenId에서 실제 ID만 추출 (URL이 입력된 경우 처리)
|
||||
function extractScreenId(input: string): string {
|
||||
if (!input) return "";
|
||||
|
||||
// URL 형태인 경우 (/pop/screens/123 또는 http://...pop/screens/123)
|
||||
const urlMatch = input.match(/\/pop\/screens\/(\d+)/);
|
||||
if (urlMatch) {
|
||||
return urlMatch[1];
|
||||
}
|
||||
|
||||
// http:// 또는 https://로 시작하는 경우 (다른 URL 형태)
|
||||
if (input.startsWith("http://") || input.startsWith("https://")) {
|
||||
// URL에서 마지막 숫자 부분 추출 시도
|
||||
const lastNumberMatch = input.match(/\/(\d+)\/?$/);
|
||||
if (lastNumberMatch) {
|
||||
return lastNumberMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// 숫자만 있는 경우 그대로 반환
|
||||
if (/^\d+$/.test(input.trim())) {
|
||||
return input.trim();
|
||||
}
|
||||
|
||||
// 그 외의 경우 원본 반환 (에러 처리는 호출부에서)
|
||||
return input;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 메인 컴포넌트
|
||||
// ========================================
|
||||
interface PopIconComponentProps {
|
||||
config?: PopIconConfig;
|
||||
label?: string;
|
||||
isDesignMode?: boolean;
|
||||
gridMode?: GridMode;
|
||||
}
|
||||
|
||||
export function PopIconComponent({
|
||||
config,
|
||||
label,
|
||||
isDesignMode,
|
||||
gridMode = "tablet_landscape"
|
||||
}: PopIconComponentProps) {
|
||||
const router = useRouter();
|
||||
const iconType = config?.iconType || "quick";
|
||||
const iconSize = getIconSizeForMode(config, gridMode);
|
||||
|
||||
// 디자인 모드 확인 다이얼로그 상태
|
||||
const [showNavigateDialog, setShowNavigateDialog] = useState(false);
|
||||
const [pendingNavigate, setPendingNavigate] = useState<{ mode: string; target: string } | null>(null);
|
||||
|
||||
// 클릭 핸들러
|
||||
const handleClick = () => {
|
||||
const navigate = config?.action?.navigate;
|
||||
if (!navigate || navigate.mode === "none") return;
|
||||
|
||||
// 디자인 모드: 확인 다이얼로그 표시
|
||||
if (isDesignMode) {
|
||||
if (navigate.mode === "screen") {
|
||||
if (!navigate.screenId) {
|
||||
toast.error("화면 ID가 설정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
const cleanScreenId = extractScreenId(navigate.screenId);
|
||||
setPendingNavigate({ mode: "screen", target: cleanScreenId });
|
||||
setShowNavigateDialog(true);
|
||||
} else if (navigate.mode === "url") {
|
||||
if (!navigate.url) {
|
||||
toast.error("URL이 설정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
setPendingNavigate({ mode: "url", target: navigate.url });
|
||||
setShowNavigateDialog(true);
|
||||
} else if (navigate.mode === "back") {
|
||||
toast.warning("뒤로가기는 실제 화면에서 테스트해주세요.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 실제 모드: 직접 실행
|
||||
switch (navigate.mode) {
|
||||
case "screen":
|
||||
if (navigate.screenId) {
|
||||
const cleanScreenId = extractScreenId(navigate.screenId);
|
||||
window.location.href = `/pop/screens/${cleanScreenId}`;
|
||||
}
|
||||
break;
|
||||
case "url":
|
||||
if (navigate.url) window.location.href = navigate.url;
|
||||
break;
|
||||
case "back":
|
||||
router.back();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 확인 후 이동 실행
|
||||
const handleConfirmNavigate = () => {
|
||||
if (!pendingNavigate) return;
|
||||
|
||||
if (pendingNavigate.mode === "screen") {
|
||||
const targetUrl = `/pop/screens/${pendingNavigate.target}`;
|
||||
console.log("[PopIcon] 화면 이동:", { target: pendingNavigate.target, url: targetUrl });
|
||||
window.location.href = targetUrl;
|
||||
} else if (pendingNavigate.mode === "url") {
|
||||
console.log("[PopIcon] URL 이동:", pendingNavigate.target);
|
||||
window.location.href = pendingNavigate.target;
|
||||
}
|
||||
|
||||
setShowNavigateDialog(false);
|
||||
setPendingNavigate(null);
|
||||
};
|
||||
|
||||
// 배경 스타일 (이미지 타입일 때는 배경 없음)
|
||||
const backgroundStyle: React.CSSProperties = iconType === "image"
|
||||
? { backgroundColor: "transparent" }
|
||||
: config?.gradient
|
||||
? buildGradientStyle(config.gradient)
|
||||
: { backgroundColor: config?.backgroundColor || "#e0e0e0" };
|
||||
|
||||
// 테두리 반경 (0% = 사각형, 100% = 원형)
|
||||
const radiusPercent = config?.borderRadiusPercent ?? 20;
|
||||
const borderRadius = iconType === "image" ? "0%" : `${radiusPercent / 2}%`;
|
||||
|
||||
// 라벨 위치에 따른 레이아웃
|
||||
const isLabelRight = config?.labelPosition === "right";
|
||||
const showLabel = config?.labelPosition !== "none" && (config?.label || label);
|
||||
|
||||
// 아이콘 렌더링
|
||||
const renderIcon = () => {
|
||||
// 빠른 선택
|
||||
if (iconType === "quick") {
|
||||
if (config?.quickSelectType === "lucide" && config?.quickSelectValue) {
|
||||
return (
|
||||
<DynamicLucideIcon
|
||||
name={config.quickSelectValue}
|
||||
size={iconSize * 0.5}
|
||||
className="text-white"
|
||||
/>
|
||||
);
|
||||
} else if (config?.quickSelectType === "emoji" && config?.quickSelectValue) {
|
||||
return <span style={{ fontSize: iconSize * 0.5 }}>{config.quickSelectValue}</span>;
|
||||
}
|
||||
// 기본값
|
||||
return <span style={{ fontSize: iconSize * 0.5 }}>📦</span>;
|
||||
}
|
||||
|
||||
// 이모지 직접 입력
|
||||
if (iconType === "emoji") {
|
||||
if (config?.quickSelectValue) {
|
||||
return <span style={{ fontSize: iconSize * 0.5 }}>{config.quickSelectValue}</span>;
|
||||
}
|
||||
return <span style={{ fontSize: iconSize * 0.5 }}>📦</span>;
|
||||
}
|
||||
|
||||
// 이미지 (배경 없이 이미지만 표시)
|
||||
if (iconType === "image" && config?.imageConfig) {
|
||||
const scale = config?.imageScale || 100;
|
||||
return (
|
||||
<img
|
||||
src={getImageUrl(config.imageConfig)}
|
||||
alt=""
|
||||
style={{
|
||||
width: `${scale}%`,
|
||||
height: `${scale}%`,
|
||||
objectFit: "contain"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <span style={{ fontSize: iconSize * 0.5 }}>📦</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center cursor-pointer transition-transform hover:scale-105",
|
||||
isLabelRight ? "flex-row gap-2" : "flex-col"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 아이콘 컨테이너 */}
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
...backgroundStyle,
|
||||
borderRadius,
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
minWidth: iconSize,
|
||||
minHeight: iconSize,
|
||||
}}
|
||||
>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
|
||||
{/* 라벨 */}
|
||||
{showLabel && (
|
||||
<span
|
||||
className={cn("truncate max-w-full", !isLabelRight && "mt-1")}
|
||||
style={{
|
||||
color: config?.labelColor || "#000000",
|
||||
fontSize: config?.labelFontSize || 12,
|
||||
}}
|
||||
>
|
||||
{config?.label || label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 디자인 모드 네비게이션 확인 다이얼로그 */}
|
||||
<AlertDialog open={showNavigateDialog} onOpenChange={setShowNavigateDialog}>
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>페이지 이동 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingNavigate?.mode === "screen"
|
||||
? "POP 화면으로 이동합니다."
|
||||
: "외부 URL로 이동합니다."
|
||||
}
|
||||
<br />
|
||||
<span className="text-red-600 font-medium">
|
||||
※ 저장하지 않은 변경사항은 사라집니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmNavigate}
|
||||
className="text-white"
|
||||
style={{ backgroundColor: "#0984e3" }}
|
||||
>
|
||||
확인 후 이동
|
||||
</AlertDialogAction>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowNavigateDialog(false);
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 설정 패널
|
||||
// ========================================
|
||||
interface PopIconConfigPanelProps {
|
||||
config: PopIconConfig;
|
||||
onUpdate: (config: PopIconConfig) => void;
|
||||
}
|
||||
|
||||
export function PopIconConfigPanel({ config, onUpdate }: PopIconConfigPanelProps) {
|
||||
const iconType = config?.iconType || "quick";
|
||||
|
||||
return (
|
||||
<div className="space-y-2 overflow-y-auto pr-1 pb-32">
|
||||
{/* 아이콘 타입 선택 */}
|
||||
<SectionDivider label="아이콘 타입" />
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={iconType}
|
||||
onValueChange={(v) => onUpdate({ ...config, iconType: v as IconType })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(ICON_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 타입별 설정 */}
|
||||
{iconType === "quick" && <QuickSelectGrid config={config} onUpdate={onUpdate} />}
|
||||
{iconType === "emoji" && <EmojiInput config={config} onUpdate={onUpdate} />}
|
||||
{iconType === "image" && <ImageUpload config={config} onUpdate={onUpdate} />}
|
||||
|
||||
{/* 라벨 설정 */}
|
||||
<SectionDivider label="라벨 설정" />
|
||||
<LabelSettings config={config} onUpdate={onUpdate} />
|
||||
|
||||
{/* 스타일 설정 (이미지 타입 제외) */}
|
||||
{iconType !== "image" && (
|
||||
<>
|
||||
<SectionDivider label="스타일 설정" />
|
||||
<StyleSettings config={config} onUpdate={onUpdate} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 액션 설정 */}
|
||||
<SectionDivider label="클릭 액션" />
|
||||
<ActionSettings config={config} onUpdate={onUpdate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 빠른 선택 그리드
|
||||
function QuickSelectGrid({ config, onUpdate }: PopIconConfigPanelProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">빠른 선택</Label>
|
||||
<div className="grid grid-cols-5 gap-1 max-h-48 overflow-y-auto p-1">
|
||||
{QUICK_SELECT_ITEMS.map((item, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => onUpdate({
|
||||
...config,
|
||||
quickSelectType: item.type,
|
||||
quickSelectValue: item.value,
|
||||
label: item.label,
|
||||
gradient: item.gradient,
|
||||
})}
|
||||
className={cn(
|
||||
"p-2 rounded border hover:border-primary transition-colors flex items-center justify-center",
|
||||
config?.quickSelectValue === item.value && "border-primary bg-primary/10"
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
{item.type === "lucide" ? (
|
||||
<DynamicLucideIcon name={item.value} size={18} className="text-gray-700" />
|
||||
) : (
|
||||
<span className="text-base">{item.value}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 이모지 직접 입력
|
||||
function EmojiInput({ config, onUpdate }: PopIconConfigPanelProps) {
|
||||
const [customEmoji, setCustomEmoji] = useState(config?.quickSelectValue || "");
|
||||
|
||||
const handleEmojiChange = (value: string) => {
|
||||
setCustomEmoji(value);
|
||||
// 이모지가 입력되면 바로 적용
|
||||
if (value.trim()) {
|
||||
onUpdate({
|
||||
...config,
|
||||
quickSelectType: "emoji",
|
||||
quickSelectValue: value,
|
||||
gradient: config?.gradient || { from: "#6c5ce7", to: "#5b4cdb" },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">이모지 입력</Label>
|
||||
<Input
|
||||
value={customEmoji}
|
||||
onChange={(e) => handleEmojiChange(e.target.value)}
|
||||
placeholder="이모지를 입력하세요 (예: 📦, 🚀)"
|
||||
className="h-8 text-xs"
|
||||
maxLength={4}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Windows: Win + . / Mac: Ctrl + Cmd + Space
|
||||
</p>
|
||||
|
||||
{/* 배경 그라디언트 설정 */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs">시작색</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={config?.gradient?.from || "#6c5ce7"}
|
||||
onChange={(e) => onUpdate({
|
||||
...config,
|
||||
gradient: { ...config?.gradient, from: e.target.value, to: config?.gradient?.to || "#5b4cdb" }
|
||||
})}
|
||||
className="h-8 w-full p-1 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs">끝색</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={config?.gradient?.to || "#5b4cdb"}
|
||||
onChange={(e) => onUpdate({
|
||||
...config,
|
||||
gradient: { ...config?.gradient, from: config?.gradient?.from || "#6c5ce7", to: e.target.value }
|
||||
})}
|
||||
className="h-8 w-full p-1 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{customEmoji && (
|
||||
<div
|
||||
className="mt-2 p-4 rounded flex items-center justify-center"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${config?.gradient?.from || "#6c5ce7"}, ${config?.gradient?.to || "#5b4cdb"})`,
|
||||
}}
|
||||
>
|
||||
<span className="text-3xl">{customEmoji}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 이미지 업로드
|
||||
function ImageUpload({ config, onUpdate }: PopIconConfigPanelProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 파일 선택 시 브라우저 캐시에 임시 저장 (DB 업로드 X)
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
// 이미지 파일 검증
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("이미지 파일만 선택할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 크기 제한 (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError("파일 크기는 5MB 이하여야 합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// FileReader로 Base64 변환 (브라우저 캐시)
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
onUpdate({
|
||||
...config,
|
||||
imageConfig: {
|
||||
tempDataUrl: reader.result as string,
|
||||
tempFileName: file.name,
|
||||
// 기존 DB 파일 정보 제거
|
||||
fileObjid: undefined,
|
||||
imageUrl: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError("파일을 읽는 중 오류가 발생했습니다.");
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// input 초기화 (같은 파일 다시 선택 가능하도록)
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// 이미지 삭제
|
||||
const handleDelete = () => {
|
||||
onUpdate({
|
||||
...config,
|
||||
imageConfig: undefined,
|
||||
imageScale: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// 미리보기 URL 가져오기
|
||||
const getPreviewUrl = (): string | undefined => {
|
||||
if (config?.imageConfig?.tempDataUrl) return config.imageConfig.tempDataUrl;
|
||||
if (config?.imageConfig?.fileObjid) return `/api/files/preview/${config.imageConfig.fileObjid}`;
|
||||
return config?.imageConfig?.imageUrl;
|
||||
};
|
||||
|
||||
const previewUrl = getPreviewUrl();
|
||||
const hasImage = !!previewUrl;
|
||||
const isTemp = !!config?.imageConfig?.tempDataUrl;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">이미지</Label>
|
||||
|
||||
{/* 파일 선택 + 삭제 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={hasImage ? "flex-1 h-8 text-xs" : "w-full h-8 text-xs"}
|
||||
>
|
||||
파일 선택
|
||||
</Button>
|
||||
{hasImage && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
className="flex-1 h-8 text-xs"
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* 또는 URL 직접 입력 */}
|
||||
<Input
|
||||
value={config?.imageConfig?.imageUrl || ""}
|
||||
onChange={(e) => onUpdate({
|
||||
...config,
|
||||
imageConfig: {
|
||||
imageUrl: e.target.value,
|
||||
// URL 입력 시 임시 파일 제거
|
||||
tempDataUrl: undefined,
|
||||
tempFileName: undefined,
|
||||
fileObjid: undefined,
|
||||
}
|
||||
})}
|
||||
placeholder="또는 URL 직접 입력..."
|
||||
className="h-8 text-xs"
|
||||
disabled={isTemp}
|
||||
/>
|
||||
|
||||
{/* 현재 이미지 미리보기 + 크기 조절 */}
|
||||
{hasImage && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="relative p-2 border rounded bg-gray-50">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="미리보기"
|
||||
className="max-h-20 mx-auto object-contain"
|
||||
/>
|
||||
{isTemp && (
|
||||
<span className="absolute top-1 right-1 text-[10px] bg-yellow-100 text-yellow-700 px-1.5 py-0.5 rounded">
|
||||
임시
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{config?.imageConfig?.tempFileName && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{config.imageConfig.tempFileName}
|
||||
</p>
|
||||
)}
|
||||
<Label className="text-xs">이미지 크기: {config?.imageScale || 100}%</Label>
|
||||
<input
|
||||
type="range"
|
||||
min={20}
|
||||
max={100}
|
||||
step={5}
|
||||
value={config?.imageScale || 100}
|
||||
onChange={(e) => onUpdate({ ...config, imageScale: Number(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
{isTemp && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
※ 화면 저장 시 서버에 업로드됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 라벨 설정
|
||||
function LabelSettings({ config, onUpdate }: PopIconConfigPanelProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={config?.label || ""}
|
||||
onChange={(e) => onUpdate({ ...config, label: e.target.value })}
|
||||
placeholder="라벨 텍스트"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select
|
||||
value={config?.labelPosition || "bottom"}
|
||||
onValueChange={(v) => onUpdate({ ...config, labelPosition: v as LabelPosition })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(LABEL_POSITION_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="color"
|
||||
value={config?.labelColor || "#000000"}
|
||||
onChange={(e) => onUpdate({ ...config, labelColor: e.target.value })}
|
||||
className="h-8 w-12 p-1 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{/* 글자 크기 슬라이더 */}
|
||||
<Label className="text-xs">글자 크기: {config?.labelFontSize || 12}px</Label>
|
||||
<input
|
||||
type="range"
|
||||
min={8}
|
||||
max={24}
|
||||
step={1}
|
||||
value={config?.labelFontSize || 12}
|
||||
onChange={(e) => onUpdate({ ...config, labelFontSize: Number(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 스타일 설정
|
||||
function StyleSettings({ config, onUpdate }: PopIconConfigPanelProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">
|
||||
모서리: {config?.borderRadiusPercent ?? 20}%
|
||||
</Label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={config?.borderRadiusPercent ?? 20}
|
||||
onChange={(e) => onUpdate({
|
||||
...config,
|
||||
borderRadiusPercent: Number(e.target.value)
|
||||
})}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 액션 설정
|
||||
function ActionSettings({ config, onUpdate }: PopIconConfigPanelProps) {
|
||||
const navigate = config?.action?.navigate || { mode: "none" as NavigateMode };
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={navigate.mode}
|
||||
onValueChange={(v) => onUpdate({
|
||||
...config,
|
||||
action: { type: "navigate", navigate: { ...navigate, mode: v as NavigateMode } }
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(NAVIGATE_MODE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 없음이 아닐 때만 추가 설정 표시 */}
|
||||
{navigate.mode !== "none" && (
|
||||
<>
|
||||
{navigate.mode === "screen" && (
|
||||
<Input
|
||||
value={navigate.screenId || ""}
|
||||
onChange={(e) => onUpdate({
|
||||
...config,
|
||||
action: { type: "navigate", navigate: { ...navigate, screenId: e.target.value } }
|
||||
})}
|
||||
placeholder="화면 ID"
|
||||
className="h-8 text-xs mt-2"
|
||||
/>
|
||||
)}
|
||||
{navigate.mode === "url" && (
|
||||
<Input
|
||||
value={navigate.url || ""}
|
||||
onChange={(e) => onUpdate({
|
||||
...config,
|
||||
action: { type: "navigate", navigate: { ...navigate, url: e.target.value } }
|
||||
})}
|
||||
placeholder="https://..."
|
||||
className="h-8 text-xs mt-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-8 text-xs mt-3"
|
||||
onClick={() => {
|
||||
if (navigate.mode === "screen" && navigate.screenId) {
|
||||
const cleanScreenId = extractScreenId(navigate.screenId);
|
||||
window.open(`/pop/screens/${cleanScreenId}`, "_blank");
|
||||
} else if (navigate.mode === "url" && navigate.url) {
|
||||
window.open(navigate.url, "_blank");
|
||||
} else if (navigate.mode === "back") {
|
||||
alert("뒤로가기는 실제 화면에서 테스트해주세요.");
|
||||
} else {
|
||||
alert("먼저 액션을 설정해주세요.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
🧪 액션 테스트 (새 탭에서 열기)
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 미리보기 컴포넌트
|
||||
// ========================================
|
||||
function PopIconPreviewComponent({ config }: { config?: PopIconConfig }) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
||||
<PopIconComponent config={config} isDesignMode={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 레지스트리 등록
|
||||
// ========================================
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-icon",
|
||||
name: "아이콘",
|
||||
description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)",
|
||||
category: "action",
|
||||
icon: "MousePointer",
|
||||
component: PopIconComponent,
|
||||
configPanel: PopIconConfigPanel,
|
||||
preview: PopIconPreviewComponent,
|
||||
defaultProps: {
|
||||
iconType: "quick",
|
||||
quickSelectType: "emoji",
|
||||
quickSelectValue: "📦",
|
||||
label: "아이콘",
|
||||
labelPosition: "bottom",
|
||||
labelColor: "#000000",
|
||||
labelFontSize: 12,
|
||||
borderRadiusPercent: 20,
|
||||
sizeMode: "auto",
|
||||
action: { type: "navigate", navigate: { mode: "none" } },
|
||||
} as PopIconConfig,
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
@ -0,0 +1,689 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Search, ChevronRight, Loader2, X } from "lucide-react";
|
||||
import { usePopEvent } from "@/hooks/pop";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import type {
|
||||
PopSearchConfig,
|
||||
DatePresetOption,
|
||||
ModalSelectConfig,
|
||||
ModalSearchMode,
|
||||
ModalFilterTab,
|
||||
} from "./types";
|
||||
import {
|
||||
DATE_PRESET_LABELS,
|
||||
computeDateRange,
|
||||
DEFAULT_SEARCH_CONFIG,
|
||||
normalizeInputType,
|
||||
MODAL_FILTER_TAB_LABELS,
|
||||
getGroupKey,
|
||||
} from "./types";
|
||||
|
||||
// ========================================
|
||||
// 메인 컴포넌트
|
||||
// ========================================
|
||||
|
||||
interface PopSearchComponentProps {
|
||||
config: PopSearchConfig;
|
||||
label?: string;
|
||||
screenId?: string;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG = DEFAULT_SEARCH_CONFIG;
|
||||
|
||||
export function PopSearchComponent({
|
||||
config: rawConfig,
|
||||
label,
|
||||
screenId,
|
||||
componentId,
|
||||
}: PopSearchComponentProps) {
|
||||
const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) };
|
||||
const { publish, subscribe, setSharedData } = usePopEvent(screenId || "");
|
||||
const [value, setValue] = useState<unknown>(config.defaultValue ?? "");
|
||||
const [modalDisplayText, setModalDisplayText] = useState("");
|
||||
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
|
||||
|
||||
const fieldKey = config.fieldName || componentId || "search";
|
||||
const normalizedType = normalizeInputType(config.inputType as string);
|
||||
const isModalType = normalizedType === "modal";
|
||||
|
||||
const emitFilterChanged = useCallback(
|
||||
(newValue: unknown) => {
|
||||
setValue(newValue);
|
||||
setSharedData(`search_${fieldKey}`, newValue);
|
||||
|
||||
if (componentId) {
|
||||
publish(`__comp_output__${componentId}__filter_value`, {
|
||||
fieldName: fieldKey,
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
|
||||
publish("filter_changed", { [fieldKey]: newValue });
|
||||
},
|
||||
[fieldKey, publish, setSharedData, componentId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__set_value`,
|
||||
(payload: unknown) => {
|
||||
const data = payload as { value?: unknown } | unknown;
|
||||
const incoming = typeof data === "object" && data && "value" in data
|
||||
? (data as { value: unknown }).value
|
||||
: data;
|
||||
emitFilterChanged(incoming);
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe, emitFilterChanged]);
|
||||
|
||||
const handleModalOpen = useCallback(() => {
|
||||
if (!config.modalConfig) return;
|
||||
setSimpleModalOpen(true);
|
||||
}, [config.modalConfig]);
|
||||
|
||||
const handleSimpleModalSelect = useCallback(
|
||||
(row: Record<string, unknown>) => {
|
||||
const mc = config.modalConfig;
|
||||
const display = mc?.displayField ? String(row[mc.displayField] ?? "") : "";
|
||||
const filterVal = mc?.valueField ? String(row[mc.valueField] ?? "") : "";
|
||||
|
||||
setModalDisplayText(display);
|
||||
emitFilterChanged(filterVal);
|
||||
setSimpleModalOpen(false);
|
||||
},
|
||||
[config.modalConfig, emitFilterChanged]
|
||||
);
|
||||
|
||||
const showLabel = config.labelVisible !== false && !!config.labelText;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full overflow-hidden",
|
||||
showLabel && config.labelPosition === "left"
|
||||
? "flex-row items-center gap-2 p-1.5"
|
||||
: "flex-col justify-center gap-0.5 p-1.5"
|
||||
)}
|
||||
>
|
||||
{showLabel && (
|
||||
<span className="shrink-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||
{config.labelText}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<SearchInputRenderer
|
||||
config={config}
|
||||
value={value}
|
||||
onChange={emitFilterChanged}
|
||||
modalDisplayText={modalDisplayText}
|
||||
onModalOpen={handleModalOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isModalType && config.modalConfig && (
|
||||
<ModalDialog
|
||||
open={simpleModalOpen}
|
||||
onOpenChange={setSimpleModalOpen}
|
||||
modalConfig={config.modalConfig}
|
||||
title={config.labelText || "선택"}
|
||||
onSelect={handleSimpleModalSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 서브타입 분기 렌더러
|
||||
// ========================================
|
||||
|
||||
interface InputRendererProps {
|
||||
config: PopSearchConfig;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
modalDisplayText?: string;
|
||||
onModalOpen?: () => void;
|
||||
}
|
||||
|
||||
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) {
|
||||
const normalized = normalizeInputType(config.inputType as string);
|
||||
switch (normalized) {
|
||||
case "text":
|
||||
case "number":
|
||||
return <TextSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
||||
case "select":
|
||||
return <SelectSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
||||
case "date-preset":
|
||||
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
|
||||
case "toggle":
|
||||
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
|
||||
case "modal":
|
||||
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} />;
|
||||
default:
|
||||
return <PlaceholderInput inputType={config.inputType} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// text 서브타입
|
||||
// ========================================
|
||||
|
||||
function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => { setInputValue(value); }, [value]);
|
||||
useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value;
|
||||
setInputValue(v);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
const ms = config.debounceMs ?? 500;
|
||||
if (ms > 0) debounceRef.current = setTimeout(() => onChange(v), ms);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && config.triggerOnEnter !== false) {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
onChange(inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
const isNumber = config.inputType === "number";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type={isNumber ? "number" : "text"}
|
||||
inputMode={isNumber ? "numeric" : undefined}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={config.placeholder || (isNumber ? "숫자 입력" : "검색어 입력")}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// select 서브타입
|
||||
// ========================================
|
||||
|
||||
function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
|
||||
return (
|
||||
<Select value={value || undefined} onValueChange={(v) => onChange(v)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={config.placeholder || "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(config.options || []).map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// date-preset 서브타입
|
||||
// ========================================
|
||||
|
||||
function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) {
|
||||
const presets: DatePresetOption[] = config.datePresets || ["today", "this-week", "this-month"];
|
||||
const currentPreset = value && typeof value === "object" && "preset" in (value as Record<string, unknown>)
|
||||
? (value as Record<string, unknown>).preset
|
||||
: value;
|
||||
|
||||
const handleSelect = (preset: DatePresetOption) => {
|
||||
if (preset === "custom") { onChange({ preset: "custom", from: "", to: "" }); return; }
|
||||
const range = computeDateRange(preset);
|
||||
if (range) onChange(range);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{presets.map((preset) => (
|
||||
<Button key={preset} variant={currentPreset === preset ? "default" : "outline"} size="sm" className="h-7 px-2 text-[10px]" onClick={() => handleSelect(preset)}>
|
||||
{DATE_PRESET_LABELS[preset]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// toggle 서브타입
|
||||
// ========================================
|
||||
|
||||
function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={value} onCheckedChange={(checked) => onChange(checked)} />
|
||||
<span className="text-xs text-muted-foreground">{value ? "ON" : "OFF"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기
|
||||
// ========================================
|
||||
|
||||
function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
|
||||
>
|
||||
<span className="flex-1 truncate text-xs">{displayText || config.placeholder || "선택..."}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 미구현 서브타입 플레이스홀더
|
||||
// ========================================
|
||||
|
||||
function PlaceholderInput({ inputType }: { inputType: string }) {
|
||||
return (
|
||||
<div className="flex h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
|
||||
<span className="text-[10px] text-muted-foreground">{inputType} (후속 구현 예정)</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 검색 방식별 문자열 매칭
|
||||
// ========================================
|
||||
|
||||
function matchSearchMode(cellValue: string, term: string, mode: ModalSearchMode): boolean {
|
||||
const lower = cellValue.toLowerCase();
|
||||
const tLower = term.toLowerCase();
|
||||
switch (mode) {
|
||||
case "starts-with": return lower.startsWith(tLower);
|
||||
case "equals": return lower === tLower;
|
||||
case "contains":
|
||||
default: return lower.includes(tLower);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 아이콘 색상 생성 (이름 기반 결정적 색상)
|
||||
// ========================================
|
||||
|
||||
const ICON_COLORS = [
|
||||
"bg-red-500", "bg-orange-500", "bg-amber-500", "bg-yellow-500",
|
||||
"bg-lime-500", "bg-green-500", "bg-emerald-500", "bg-teal-500",
|
||||
"bg-cyan-500", "bg-sky-500", "bg-blue-500", "bg-indigo-500",
|
||||
"bg-violet-500", "bg-purple-500", "bg-fuchsia-500", "bg-pink-500",
|
||||
];
|
||||
|
||||
function getIconColor(text: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash = text.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return ICON_COLORS[Math.abs(hash) % ICON_COLORS.length];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 모달 Dialog: 테이블 / 아이콘 뷰 + 필터 탭
|
||||
// ========================================
|
||||
|
||||
interface ModalDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
modalConfig: ModalSelectConfig;
|
||||
title: string;
|
||||
onSelect: (row: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: ModalDialogProps) {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeFilterTab, setActiveFilterTab] = useState<ModalFilterTab | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const {
|
||||
tableName,
|
||||
displayColumns,
|
||||
searchColumns,
|
||||
searchMode = "contains",
|
||||
filterTabs,
|
||||
columnLabels,
|
||||
displayStyle = "table",
|
||||
displayField,
|
||||
} = modalConfig;
|
||||
|
||||
const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : [];
|
||||
const hasFilterTabs = filterTabs && filterTabs.length > 0;
|
||||
|
||||
// 데이터 로드
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!tableName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await dataApi.getTableData(tableName, { page: 1, size: 200 });
|
||||
setAllRows(result.data || []);
|
||||
} catch {
|
||||
setAllRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchText("");
|
||||
setActiveFilterTab(hasFilterTabs ? filterTabs![0] : null);
|
||||
fetchData();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, fetchData, hasFilterTabs]);
|
||||
|
||||
// 필터링된 행 계산
|
||||
const filteredRows = useMemo(() => {
|
||||
let items = allRows;
|
||||
|
||||
// 텍스트 검색 필터
|
||||
if (searchText.trim()) {
|
||||
const cols = searchColumns && searchColumns.length > 0 ? searchColumns : colsToShow;
|
||||
items = items.filter((row) =>
|
||||
cols.some((col) => {
|
||||
const val = row[col];
|
||||
return val != null && matchSearchMode(String(val), searchText, searchMode);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 필터 탭 (초성/알파벳) 적용
|
||||
if (activeFilterTab && displayField) {
|
||||
items = items.filter((row) => {
|
||||
const val = row[displayField];
|
||||
if (val == null) return false;
|
||||
const key = getGroupKey(String(val), activeFilterTab);
|
||||
return key !== "#";
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [allRows, searchText, searchColumns, colsToShow, searchMode, activeFilterTab, displayField]);
|
||||
|
||||
// 그룹화 (필터 탭 활성화 시)
|
||||
const groupedRows = useMemo(() => {
|
||||
if (!activeFilterTab || !displayField) return null;
|
||||
|
||||
const groups = new Map<string, Record<string, unknown>[]>();
|
||||
for (const row of filteredRows) {
|
||||
const val = row[displayField];
|
||||
const key = val != null ? getGroupKey(String(val), activeFilterTab) : "#";
|
||||
if (key === "#") continue;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(row);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
const sorted = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b, "ko"));
|
||||
return sorted;
|
||||
}, [filteredRows, activeFilterTab, displayField]);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value;
|
||||
setSearchText(v);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {}, 300);
|
||||
};
|
||||
|
||||
const getColLabel = (colName: string) => columnLabels?.[colName] || colName;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader className="flex flex-row items-center justify-between">
|
||||
<DialogTitle className="text-base sm:text-lg">{title} 선택</DialogTitle>
|
||||
{/* 필터 탭 버튼 */}
|
||||
{hasFilterTabs && (
|
||||
<div className="flex gap-1">
|
||||
{filterTabs!.map((tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
variant={activeFilterTab === tab ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 px-3 text-[11px]"
|
||||
onClick={() => setActiveFilterTab(activeFilterTab === tab ? null : tab)}
|
||||
>
|
||||
{MODAL_FILTER_TAB_LABELS[tab]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="검색..."
|
||||
className="h-9 pl-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
{searchText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchText("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 결과 영역 */}
|
||||
<div className="max-h-[50vh] overflow-auto rounded-md border">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{searchText ? "검색 결과가 없습니다" : "데이터가 없습니다"}
|
||||
</div>
|
||||
) : displayStyle === "icon" ? (
|
||||
<IconView
|
||||
rows={filteredRows}
|
||||
groupedRows={groupedRows}
|
||||
displayField={displayField || ""}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : (
|
||||
<TableView
|
||||
rows={filteredRows}
|
||||
groupedRows={groupedRows}
|
||||
colsToShow={colsToShow}
|
||||
displayField={displayField || ""}
|
||||
getColLabel={getColLabel}
|
||||
activeFilterTab={activeFilterTab}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{filteredRows.length}건 표시 / {displayStyle === "icon" ? "아이콘" : "행"}을 클릭하면 선택됩니다
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 테이블 뷰
|
||||
// ========================================
|
||||
|
||||
function TableView({
|
||||
rows,
|
||||
groupedRows,
|
||||
colsToShow,
|
||||
displayField,
|
||||
getColLabel,
|
||||
activeFilterTab,
|
||||
onSelect,
|
||||
}: {
|
||||
rows: Record<string, unknown>[];
|
||||
groupedRows: [string, Record<string, unknown>[]][] | null;
|
||||
colsToShow: string[];
|
||||
displayField: string;
|
||||
getColLabel: (col: string) => string;
|
||||
activeFilterTab: ModalFilterTab | null;
|
||||
onSelect: (row: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const renderRow = (row: Record<string, unknown>, i: number) => (
|
||||
<tr key={i} className="cursor-pointer border-t transition-colors hover:bg-accent" onClick={() => onSelect(row)}>
|
||||
{colsToShow.length > 0
|
||||
? colsToShow.map((col) => (
|
||||
<td key={col} className="px-3 py-2 text-xs">{String(row[col] ?? "")}</td>
|
||||
))
|
||||
: Object.entries(row).slice(0, 3).map(([k, v]) => (
|
||||
<td key={k} className="px-3 py-2 text-xs">{String(v ?? "")}</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (groupedRows && activeFilterTab) {
|
||||
return (
|
||||
<div>
|
||||
{colsToShow.length > 0 && (
|
||||
<div className="sticky top-0 z-10 flex bg-muted">
|
||||
{colsToShow.map((col) => (
|
||||
<div key={col} className="flex-1 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{getColLabel(col)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{groupedRows.map(([groupKey, groupRows]) => (
|
||||
<div key={groupKey}>
|
||||
<div className="sticky top-8 z-5 flex items-center gap-2 bg-background px-3 py-1.5">
|
||||
<span className="text-sm font-semibold text-primary">{groupKey}</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{groupRows.map((row, i) => renderRow(row, i))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
{colsToShow.length > 0 && (
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr>
|
||||
{colsToShow.map((col) => (
|
||||
<th key={col} className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{getColLabel(col)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{rows.map((row, i) => renderRow(row, i))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 아이콘 뷰
|
||||
// ========================================
|
||||
|
||||
function IconView({
|
||||
rows,
|
||||
groupedRows,
|
||||
displayField,
|
||||
onSelect,
|
||||
}: {
|
||||
rows: Record<string, unknown>[];
|
||||
groupedRows: [string, Record<string, unknown>[]][] | null;
|
||||
displayField: string;
|
||||
onSelect: (row: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const renderIconCard = (row: Record<string, unknown>, i: number) => {
|
||||
const text = displayField ? String(row[displayField] ?? "") : "";
|
||||
const firstChar = text.charAt(0) || "?";
|
||||
const color = getIconColor(text);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex w-20 cursor-pointer flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-accent"
|
||||
onClick={() => onSelect(row)}
|
||||
>
|
||||
<div className={cn("flex h-14 w-14 items-center justify-center rounded-xl text-xl font-bold text-white", color)}>
|
||||
{firstChar}
|
||||
</div>
|
||||
<span className="w-full truncate text-center text-[11px]">{text}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (groupedRows) {
|
||||
return (
|
||||
<div className="p-3">
|
||||
{groupedRows.map(([groupKey, groupRows]) => (
|
||||
<div key={groupKey} className="mb-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-primary">{groupKey}</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groupRows.map((row, i) => renderIconCard(row, i))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 p-3">
|
||||
{rows.map((row, i) => renderIconCard(row, i))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,648 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import type {
|
||||
PopSearchConfig,
|
||||
SearchInputType,
|
||||
DatePresetOption,
|
||||
ModalSelectConfig,
|
||||
ModalDisplayStyle,
|
||||
ModalSearchMode,
|
||||
ModalFilterTab,
|
||||
} from "./types";
|
||||
import {
|
||||
SEARCH_INPUT_TYPE_LABELS,
|
||||
DATE_PRESET_LABELS,
|
||||
MODAL_DISPLAY_STYLE_LABELS,
|
||||
MODAL_SEARCH_MODE_LABELS,
|
||||
MODAL_FILTER_TAB_LABELS,
|
||||
normalizeInputType,
|
||||
} from "./types";
|
||||
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
|
||||
import type { TableInfo, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
||||
|
||||
// ========================================
|
||||
// 기본값
|
||||
// ========================================
|
||||
|
||||
const DEFAULT_CONFIG: PopSearchConfig = {
|
||||
inputType: "text",
|
||||
fieldName: "",
|
||||
placeholder: "검색어 입력",
|
||||
debounceMs: 500,
|
||||
triggerOnEnter: true,
|
||||
labelPosition: "top",
|
||||
labelText: "",
|
||||
labelVisible: true,
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 설정 패널 메인
|
||||
// ========================================
|
||||
|
||||
interface ConfigPanelProps {
|
||||
config: PopSearchConfig | undefined;
|
||||
onUpdate: (config: PopSearchConfig) => void;
|
||||
}
|
||||
|
||||
export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) };
|
||||
const cfg: PopSearchConfig = {
|
||||
...rawCfg,
|
||||
inputType: normalizeInputType(rawCfg.inputType as string),
|
||||
};
|
||||
|
||||
const update = (partial: Partial<PopSearchConfig>) => {
|
||||
onUpdate({ ...cfg, ...partial });
|
||||
};
|
||||
|
||||
const STEPS = ["기본 설정", "상세 설정"];
|
||||
|
||||
return (
|
||||
<div className="space-y-3 overflow-y-auto pr-1 pb-32">
|
||||
{/* Stepper 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2">
|
||||
{STEPS.map((s, i) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setStep(i)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-2 py-1 text-[10px] font-medium transition-colors",
|
||||
step === i
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-background text-[8px] font-bold text-foreground">
|
||||
{i + 1}
|
||||
</span>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 0 && <StepBasicSettings cfg={cfg} update={update} />}
|
||||
{step === 1 && <StepDetailSettings cfg={cfg} update={update} />}
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px]"
|
||||
disabled={step === 0}
|
||||
onClick={() => setStep(step - 1)}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-3 w-3" />
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px]"
|
||||
disabled={step === STEPS.length - 1}
|
||||
onClick={() => setStep(step + 1)}
|
||||
>
|
||||
다음
|
||||
<ChevronRight className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// STEP 1: 기본 설정
|
||||
// ========================================
|
||||
|
||||
interface StepProps {
|
||||
cfg: PopSearchConfig;
|
||||
update: (partial: Partial<PopSearchConfig>) => void;
|
||||
}
|
||||
|
||||
function StepBasicSettings({ cfg, update }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">입력 타입</Label>
|
||||
<Select
|
||||
value={normalizeInputType(cfg.inputType as string)}
|
||||
onValueChange={(v) => update({ inputType: v as SearchInputType })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(SEARCH_INPUT_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">플레이스홀더</Label>
|
||||
<Input
|
||||
value={cfg.placeholder || ""}
|
||||
onChange={(e) => update({ placeholder: e.target.value })}
|
||||
placeholder="입력 힌트 텍스트"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="labelVisible"
|
||||
checked={cfg.labelVisible !== false}
|
||||
onCheckedChange={(checked) => update({ labelVisible: Boolean(checked) })}
|
||||
/>
|
||||
<Label htmlFor="labelVisible" className="text-[10px]">라벨 표시</Label>
|
||||
</div>
|
||||
|
||||
{cfg.labelVisible !== false && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">라벨 텍스트</Label>
|
||||
<Input
|
||||
value={cfg.labelText || ""}
|
||||
onChange={(e) => update({ labelText: e.target.value })}
|
||||
placeholder="예: 거래처명"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">라벨 위치</Label>
|
||||
<Select
|
||||
value={cfg.labelPosition || "top"}
|
||||
onValueChange={(v) => update({ labelPosition: v as "top" | "left" })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top" className="text-xs">위 (기본)</SelectItem>
|
||||
<SelectItem value="left" className="text-xs">왼쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// STEP 2: 타입별 상세 설정
|
||||
// ========================================
|
||||
|
||||
function StepDetailSettings({ cfg, update }: StepProps) {
|
||||
const normalized = normalizeInputType(cfg.inputType as string);
|
||||
switch (normalized) {
|
||||
case "text":
|
||||
case "number":
|
||||
return <TextDetailSettings cfg={cfg} update={update} />;
|
||||
case "select":
|
||||
return <SelectDetailSettings cfg={cfg} update={update} />;
|
||||
case "date-preset":
|
||||
return <DatePresetDetailSettings cfg={cfg} update={update} />;
|
||||
case "modal":
|
||||
return <ModalDetailSettings cfg={cfg} update={update} />;
|
||||
case "toggle":
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
토글은 추가 설정이 없습니다. ON/OFF 값이 바로 전달됩니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{cfg.inputType} 타입의 상세 설정은 후속 구현 예정입니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// text/number 상세 설정
|
||||
// ========================================
|
||||
|
||||
function TextDetailSettings({ cfg, update }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">디바운스 (ms)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={cfg.debounceMs ?? 500}
|
||||
onChange={(e) => update({ debounceMs: Math.max(0, Number(e.target.value)) })}
|
||||
min={0}
|
||||
max={5000}
|
||||
step={100}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
입력 후 대기 시간. 0이면 즉시 발행 (권장: 300~500)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="triggerOnEnter"
|
||||
checked={cfg.triggerOnEnter !== false}
|
||||
onCheckedChange={(checked) => update({ triggerOnEnter: Boolean(checked) })}
|
||||
/>
|
||||
<Label htmlFor="triggerOnEnter" className="text-[10px]">Enter 키로 즉시 발행</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// select 상세 설정
|
||||
// ========================================
|
||||
|
||||
function SelectDetailSettings({ cfg, update }: StepProps) {
|
||||
const options = cfg.options || [];
|
||||
|
||||
const addOption = () => {
|
||||
update({
|
||||
options: [...options, { value: `opt_${options.length + 1}`, label: `옵션 ${options.length + 1}` }],
|
||||
});
|
||||
};
|
||||
|
||||
const removeOption = (index: number) => {
|
||||
update({ options: options.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
const updateOption = (index: number, field: "value" | "label", val: string) => {
|
||||
update({ options: options.map((opt, i) => (i === index ? { ...opt, [field]: val } : opt)) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-[10px]">옵션 목록</Label>
|
||||
{options.length === 0 && (
|
||||
<p className="text-[9px] text-muted-foreground">옵션이 없습니다. 아래 버튼으로 추가하세요.</p>
|
||||
)}
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<Input value={opt.value} onChange={(e) => updateOption(i, "value", e.target.value)} placeholder="값" className="h-7 flex-1 text-[10px]" />
|
||||
<Input value={opt.label} onChange={(e) => updateOption(i, "label", e.target.value)} placeholder="라벨" className="h-7 flex-1 text-[10px]" />
|
||||
<button type="button" onClick={() => removeOption(i)} className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" className="h-7 w-full text-[10px]" onClick={addOption}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
옵션 추가
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// date-preset 상세 설정
|
||||
// ========================================
|
||||
|
||||
function DatePresetDetailSettings({ cfg, update }: StepProps) {
|
||||
const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"];
|
||||
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
|
||||
|
||||
const togglePreset = (preset: DatePresetOption) => {
|
||||
const next = activePresets.includes(preset)
|
||||
? activePresets.filter((p) => p !== preset)
|
||||
: [...activePresets, preset];
|
||||
update({ datePresets: next.length > 0 ? next : ["today"] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-[10px]">활성화할 프리셋</Label>
|
||||
{ALL_PRESETS.map((preset) => (
|
||||
<div key={preset} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`preset_${preset}`}
|
||||
checked={activePresets.includes(preset)}
|
||||
onCheckedChange={() => togglePreset(preset)}
|
||||
/>
|
||||
<Label htmlFor={`preset_${preset}`} className="text-[10px]">{DATE_PRESET_LABELS[preset]}</Label>
|
||||
</div>
|
||||
))}
|
||||
{activePresets.includes("custom") && (
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
"직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// modal 상세 설정
|
||||
// ========================================
|
||||
|
||||
const DEFAULT_MODAL_CONFIG: ModalSelectConfig = {
|
||||
displayStyle: "table",
|
||||
displayField: "",
|
||||
valueField: "",
|
||||
searchMode: "contains",
|
||||
};
|
||||
|
||||
function ModalDetailSettings({ cfg, update }: StepProps) {
|
||||
const mc: ModalSelectConfig = { ...DEFAULT_MODAL_CONFIG, ...(cfg.modalConfig || {}) };
|
||||
|
||||
const updateModal = (partial: Partial<ModalSelectConfig>) => {
|
||||
update({ modalConfig: { ...mc, ...partial } });
|
||||
};
|
||||
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setTablesLoading(true);
|
||||
tableManagementApi.getTableList().then((res) => {
|
||||
if (!cancelled && res.success && res.data) setTables(res.data);
|
||||
}).finally(() => !cancelled && setTablesLoading(false));
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mc.tableName) { setColumns([]); return; }
|
||||
let cancelled = false;
|
||||
setColumnsLoading(true);
|
||||
getTableColumns(mc.tableName).then((res) => {
|
||||
if (!cancelled && res.success && res.data?.columns) setColumns(res.data.columns);
|
||||
}).finally(() => !cancelled && setColumnsLoading(false));
|
||||
return () => { cancelled = true; };
|
||||
}, [mc.tableName]);
|
||||
|
||||
const toggleArrayItem = (field: "displayColumns" | "searchColumns", col: string) => {
|
||||
const current = mc[field] || [];
|
||||
const next = current.includes(col) ? current.filter((c) => c !== col) : [...current, col];
|
||||
updateModal({ [field]: next });
|
||||
};
|
||||
|
||||
const toggleFilterTab = (tab: ModalFilterTab) => {
|
||||
const current = mc.filterTabs || [];
|
||||
const next = current.includes(tab) ? current.filter((t) => t !== tab) : [...current, tab];
|
||||
updateModal({ filterTabs: next });
|
||||
};
|
||||
|
||||
const updateColumnLabel = (colName: string, label: string) => {
|
||||
const current = mc.columnLabels || {};
|
||||
if (!label.trim()) {
|
||||
const { [colName]: _, ...rest } = current;
|
||||
updateModal({ columnLabels: Object.keys(rest).length > 0 ? rest : undefined });
|
||||
} else {
|
||||
updateModal({ columnLabels: { ...current, [colName]: label } });
|
||||
}
|
||||
};
|
||||
|
||||
const selectedDisplayCols = mc.displayColumns || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 보여주기 방식 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">보여주기 방식</Label>
|
||||
<Select
|
||||
value={mc.displayStyle || "table"}
|
||||
onValueChange={(v) => updateModal({ displayStyle: v as ModalDisplayStyle })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(MODAL_DISPLAY_STYLE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
테이블: 표 형태 / 아이콘: 아이콘 카드 형태
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">데이터 테이블</Label>
|
||||
{tablesLoading ? (
|
||||
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
테이블 목록 로딩...
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={mc.tableName || undefined}
|
||||
onValueChange={(v) =>
|
||||
updateModal({ tableName: v, displayColumns: [], searchColumns: [], displayField: "", valueField: "", columnLabels: undefined })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.tableName} value={t.tableName} className="text-xs">
|
||||
{t.displayName || t.tableName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mc.tableName && (
|
||||
<>
|
||||
{/* 표시할 컬럼 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">표시할 컬럼</Label>
|
||||
{columnsLoading ? (
|
||||
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
컬럼 로딩...
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
|
||||
{columns.map((col) => (
|
||||
<div key={col.columnName} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`disp_${col.columnName}`}
|
||||
checked={mc.displayColumns?.includes(col.columnName) ?? false}
|
||||
onCheckedChange={() => toggleArrayItem("displayColumns", col.columnName)}
|
||||
/>
|
||||
<Label htmlFor={`disp_${col.columnName}`} className="text-[10px]">
|
||||
{col.displayName || col.columnName}
|
||||
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 컬럼 헤더 라벨 편집 (표시할 컬럼이 선택된 경우만) */}
|
||||
{selectedDisplayCols.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">컬럼 헤더 라벨</Label>
|
||||
<div className="space-y-1 rounded border p-2">
|
||||
{selectedDisplayCols.map((colName) => {
|
||||
const colInfo = columns.find((c) => c.columnName === colName);
|
||||
const defaultLabel = colInfo?.displayName || colName;
|
||||
return (
|
||||
<div key={colName} className="flex items-center gap-2">
|
||||
<span className="w-24 shrink-0 truncate text-[9px] text-muted-foreground">
|
||||
{colName}
|
||||
</span>
|
||||
<Input
|
||||
value={mc.columnLabels?.[colName] ?? ""}
|
||||
onChange={(e) => updateColumnLabel(colName, e.target.value)}
|
||||
placeholder={defaultLabel}
|
||||
className="h-6 flex-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
비워두면 기본 컬럼명이 사용됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 대상 컬럼 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">검색 대상 컬럼</Label>
|
||||
<div className="max-h-24 space-y-1 overflow-y-auto rounded border p-2">
|
||||
{columns.map((col) => (
|
||||
<div key={col.columnName} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`search_${col.columnName}`}
|
||||
checked={mc.searchColumns?.includes(col.columnName) ?? false}
|
||||
onCheckedChange={() => toggleArrayItem("searchColumns", col.columnName)}
|
||||
/>
|
||||
<Label htmlFor={`search_${col.columnName}`} className="text-[10px]">
|
||||
{col.displayName || col.columnName}
|
||||
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 방식 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">검색 방식</Label>
|
||||
<Select
|
||||
value={mc.searchMode || "contains"}
|
||||
onValueChange={(v) => updateModal({ searchMode: v as ModalSearchMode })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(MODAL_SEARCH_MODE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
포함: 어디든 일치 / 시작: 앞에서 일치 / 같음: 정확히 일치
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 필터 탭 (가나다/ABC) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">필터 탭</Label>
|
||||
<div className="flex gap-3">
|
||||
{(Object.entries(MODAL_FILTER_TAB_LABELS) as [ModalFilterTab, string][]).map(([key, label]) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
id={`ftab_${key}`}
|
||||
checked={mc.filterTabs?.includes(key) ?? false}
|
||||
onCheckedChange={() => toggleFilterTab(key)}
|
||||
/>
|
||||
<Label htmlFor={`ftab_${key}`} className="text-[10px]">{label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
모달 상단에 초성(가나다) / 알파벳(ABC) 필터 탭 표시
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 검색창에 보일 값 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">검색창에 보일 값</Label>
|
||||
<Select
|
||||
value={mc.displayField || "__none__"}
|
||||
onValueChange={(v) => updateModal({ displayField: v === "__none__" ? "" : v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-xs text-muted-foreground">선택 안 함</SelectItem>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
{col.displayName || col.columnName} ({col.columnName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
선택 후 검색 입력란에 표시될 값 (예: 회사명)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 필터에 쓸 값 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">필터에 쓸 값</Label>
|
||||
<Select
|
||||
value={mc.valueField || "__none__"}
|
||||
onValueChange={(v) => updateModal({ valueField: v === "__none__" ? "" : v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-xs text-muted-foreground">선택 안 함</SelectItem>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
{col.displayName || col.columnName} ({col.columnName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
연결된 리스트를 필터할 때 사용할 값 (예: 회사코드)
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||
import { PopSearchComponent } from "./PopSearchComponent";
|
||||
import { PopSearchConfigPanel } from "./PopSearchConfig";
|
||||
import type { PopSearchConfig } from "./types";
|
||||
import { DEFAULT_SEARCH_CONFIG } from "./types";
|
||||
|
||||
function PopSearchPreviewComponent({ config, label }: { config?: PopSearchConfig; label?: string }) {
|
||||
const cfg = config || DEFAULT_SEARCH_CONFIG;
|
||||
const displayLabel = cfg.labelText || label || cfg.fieldName || "검색";
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
{displayLabel}
|
||||
</span>
|
||||
<div className="flex h-6 w-full items-center rounded border border-dashed border-muted-foreground/30 px-2">
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{cfg.placeholder || cfg.inputType}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-search",
|
||||
name: "검색",
|
||||
description: "조건 입력 (텍스트/날짜/선택/모달)",
|
||||
category: "input",
|
||||
icon: "Search",
|
||||
component: PopSearchComponent,
|
||||
configPanel: PopSearchConfigPanel,
|
||||
preview: PopSearchPreviewComponent,
|
||||
defaultProps: DEFAULT_SEARCH_CONFIG,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{ key: "filter_value", label: "필터 값", type: "filter_value", description: "입력한 검색 조건을 다른 컴포넌트에 전달" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "set_value", label: "값 설정", type: "filter_value", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" },
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
// ===== pop-search 전용 타입 =====
|
||||
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
|
||||
|
||||
/** 검색 필드 입력 타입 (9종) */
|
||||
export type SearchInputType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "date"
|
||||
| "date-preset"
|
||||
| "select"
|
||||
| "multi-select"
|
||||
| "combo"
|
||||
| "modal"
|
||||
| "toggle";
|
||||
|
||||
/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */
|
||||
export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid";
|
||||
|
||||
/** 레거시 타입 -> modal로 정규화 */
|
||||
export function normalizeInputType(t: string): SearchInputType {
|
||||
if (t === "modal-table" || t === "modal-card" || t === "modal-icon-grid") return "modal";
|
||||
return t as SearchInputType;
|
||||
}
|
||||
|
||||
/** 날짜 프리셋 옵션 */
|
||||
export type DatePresetOption = "today" | "this-week" | "this-month" | "custom";
|
||||
|
||||
/** 셀렉트 옵션 (정적 목록) */
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 셀렉트 옵션 데이터 소스 (DB에서 동적 로딩) */
|
||||
export interface SelectDataSource {
|
||||
tableName: string;
|
||||
valueColumn: string;
|
||||
labelColumn: string;
|
||||
sortColumn?: string;
|
||||
sortDirection?: "asc" | "desc";
|
||||
}
|
||||
|
||||
/** 모달 보여주기 방식: 테이블 or 아이콘 */
|
||||
export type ModalDisplayStyle = "table" | "icon";
|
||||
|
||||
/** 모달 검색 방식 */
|
||||
export type ModalSearchMode = "contains" | "starts-with" | "equals";
|
||||
|
||||
/** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */
|
||||
export type ModalFilterTab = "korean" | "alphabet";
|
||||
|
||||
/** 모달 선택 설정 */
|
||||
export interface ModalSelectConfig {
|
||||
displayStyle?: ModalDisplayStyle;
|
||||
|
||||
tableName?: string;
|
||||
displayColumns?: string[];
|
||||
/** 컬럼별 커스텀 헤더 라벨 { column_name: "표시 라벨" } */
|
||||
columnLabels?: Record<string, string>;
|
||||
searchColumns?: string[];
|
||||
searchMode?: ModalSearchMode;
|
||||
/** 모달 상단 필터 탭 (가나다 / ABC) */
|
||||
filterTabs?: ModalFilterTab[];
|
||||
|
||||
displayField: string;
|
||||
valueField: string;
|
||||
}
|
||||
|
||||
/** pop-search 전체 설정 */
|
||||
export interface PopSearchConfig {
|
||||
inputType: SearchInputType | LegacySearchInputType;
|
||||
fieldName: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: unknown;
|
||||
|
||||
// text/number 전용
|
||||
debounceMs?: number;
|
||||
triggerOnEnter?: boolean;
|
||||
|
||||
// select/multi-select 전용
|
||||
options?: SelectOption[];
|
||||
optionsDataSource?: SelectDataSource;
|
||||
|
||||
// date-preset 전용
|
||||
datePresets?: DatePresetOption[];
|
||||
|
||||
// modal 전용
|
||||
modalConfig?: ModalSelectConfig;
|
||||
|
||||
// 라벨
|
||||
labelText?: string;
|
||||
labelVisible?: boolean;
|
||||
|
||||
// 스타일
|
||||
labelPosition?: "top" | "left";
|
||||
}
|
||||
|
||||
/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */
|
||||
export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = {
|
||||
inputType: "text",
|
||||
fieldName: "",
|
||||
placeholder: "검색어 입력",
|
||||
debounceMs: 500,
|
||||
triggerOnEnter: true,
|
||||
labelPosition: "top",
|
||||
labelText: "",
|
||||
labelVisible: true,
|
||||
};
|
||||
|
||||
/** 날짜 프리셋 라벨 매핑 */
|
||||
export const DATE_PRESET_LABELS: Record<DatePresetOption, string> = {
|
||||
today: "오늘",
|
||||
"this-week": "이번주",
|
||||
"this-month": "이번달",
|
||||
custom: "직접",
|
||||
};
|
||||
|
||||
/** 입력 타입 라벨 매핑 (설정 패널용) */
|
||||
export const SEARCH_INPUT_TYPE_LABELS: Record<SearchInputType, string> = {
|
||||
text: "텍스트",
|
||||
number: "숫자",
|
||||
date: "날짜",
|
||||
"date-preset": "날짜 프리셋",
|
||||
select: "단일 선택",
|
||||
"multi-select": "다중 선택",
|
||||
combo: "자동완성",
|
||||
modal: "모달",
|
||||
toggle: "토글",
|
||||
};
|
||||
|
||||
/** 모달 보여주기 방식 라벨 */
|
||||
export const MODAL_DISPLAY_STYLE_LABELS: Record<ModalDisplayStyle, string> = {
|
||||
table: "테이블",
|
||||
icon: "아이콘",
|
||||
};
|
||||
|
||||
/** 모달 검색 방식 라벨 */
|
||||
export const MODAL_SEARCH_MODE_LABELS: Record<ModalSearchMode, string> = {
|
||||
contains: "포함",
|
||||
"starts-with": "시작",
|
||||
equals: "같음",
|
||||
};
|
||||
|
||||
/** 모달 필터 탭 라벨 */
|
||||
export const MODAL_FILTER_TAB_LABELS: Record<ModalFilterTab, string> = {
|
||||
korean: "가나다",
|
||||
alphabet: "ABC",
|
||||
};
|
||||
|
||||
/** 한글 초성 추출 */
|
||||
const KOREAN_CONSONANTS = [
|
||||
"ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",
|
||||
"ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ",
|
||||
];
|
||||
|
||||
/** 초성 -> 대표 초성 (쌍자음 합침) */
|
||||
const CONSONANT_GROUP: Record<string, string> = {
|
||||
"ㄱ": "ㄱ", "ㄲ": "ㄱ",
|
||||
"ㄴ": "ㄴ",
|
||||
"ㄷ": "ㄷ", "ㄸ": "ㄷ",
|
||||
"ㄹ": "ㄹ",
|
||||
"ㅁ": "ㅁ",
|
||||
"ㅂ": "ㅂ", "ㅃ": "ㅂ",
|
||||
"ㅅ": "ㅅ", "ㅆ": "ㅅ",
|
||||
"ㅇ": "ㅇ",
|
||||
"ㅈ": "ㅈ", "ㅉ": "ㅈ",
|
||||
"ㅊ": "ㅊ",
|
||||
"ㅋ": "ㅋ",
|
||||
"ㅌ": "ㅌ",
|
||||
"ㅍ": "ㅍ",
|
||||
"ㅎ": "ㅎ",
|
||||
};
|
||||
|
||||
/** 문자열 첫 글자의 그룹 키 추출 (한글 초성 / 영문 대문자 / 기타) */
|
||||
export function getGroupKey(
|
||||
text: string,
|
||||
mode: ModalFilterTab
|
||||
): string {
|
||||
if (!text) return "#";
|
||||
const ch = text.charAt(0);
|
||||
const code = ch.charCodeAt(0);
|
||||
|
||||
if (mode === "korean") {
|
||||
if (code >= 0xAC00 && code <= 0xD7A3) {
|
||||
const idx = Math.floor((code - 0xAC00) / (21 * 28));
|
||||
const consonant = KOREAN_CONSONANTS[idx];
|
||||
return CONSONANT_GROUP[consonant] || consonant;
|
||||
}
|
||||
return "#";
|
||||
}
|
||||
|
||||
// alphabet
|
||||
if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) {
|
||||
return ch.toUpperCase();
|
||||
}
|
||||
return "#";
|
||||
}
|
||||
|
||||
/** 날짜 범위 계산 (date-preset -> 실제 날짜) */
|
||||
export function computeDateRange(
|
||||
preset: DatePresetOption
|
||||
): { preset: DatePresetOption; from: string; to: string } | null {
|
||||
const now = new Date();
|
||||
const fmt = (d: Date) => d.toISOString().split("T")[0];
|
||||
|
||||
switch (preset) {
|
||||
case "today":
|
||||
return { preset, from: fmt(now), to: fmt(now) };
|
||||
case "this-week": {
|
||||
const day = now.getDay();
|
||||
const mon = new Date(now);
|
||||
mon.setDate(now.getDate() - ((day + 6) % 7));
|
||||
const sun = new Date(mon);
|
||||
sun.setDate(mon.getDate() + 6);
|
||||
return { preset, from: fmt(mon), to: fmt(sun) };
|
||||
}
|
||||
case "this-month": {
|
||||
const first = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const last = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
return { preset, from: fmt(first), to: fmt(last) };
|
||||
}
|
||||
case "custom":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,795 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* pop-string-list 런타임 컴포넌트
|
||||
*
|
||||
* 리스트 모드: 엑셀형 행/열 (CSS Grid)
|
||||
* 카드 모드: 셀 병합 가능한 카드 (CSS Grid + colSpan/rowSpan)
|
||||
* 오버플로우: visibleRows 제한 + "더보기" 점진 확장
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { executePopAction } from "@/hooks/pop/executePopAction";
|
||||
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
PopStringListConfig,
|
||||
CardGridConfig,
|
||||
ListColumnConfig,
|
||||
CardCellDefinition,
|
||||
} from "./types";
|
||||
|
||||
// ===== 유틸리티 =====
|
||||
|
||||
/**
|
||||
* 컬럼명에서 실제 데이터 키를 추출
|
||||
* 조인 컬럼은 "테이블명.컬럼명" 형식으로 저장됨 -> "컬럼명"만 추출
|
||||
* 일반 컬럼은 그대로 반환
|
||||
*/
|
||||
function resolveColumnName(name: string): string {
|
||||
if (!name) return name;
|
||||
const dotIdx = name.lastIndexOf(".");
|
||||
return dotIdx >= 0 ? name.substring(dotIdx + 1) : name;
|
||||
}
|
||||
|
||||
// ===== Props =====
|
||||
|
||||
interface PopStringListComponentProps {
|
||||
config?: PopStringListConfig;
|
||||
className?: string;
|
||||
screenId?: string;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
// 테이블 행 데이터 타입
|
||||
type RowData = Record<string, unknown>;
|
||||
|
||||
// ===== 메인 컴포넌트 =====
|
||||
|
||||
export function PopStringListComponent({
|
||||
config,
|
||||
className,
|
||||
screenId,
|
||||
componentId,
|
||||
}: PopStringListComponentProps) {
|
||||
const displayMode = config?.displayMode || "list";
|
||||
const header = config?.header;
|
||||
const overflow = config?.overflow;
|
||||
const dataSource = config?.dataSource;
|
||||
const listColumns = config?.listColumns || [];
|
||||
const cardGrid = config?.cardGrid;
|
||||
const rowClickAction = config?.rowClickAction || "none";
|
||||
|
||||
// 데이터 상태
|
||||
const [rows, setRows] = useState<RowData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 더보기 모드: 현재 표시 중인 행 수
|
||||
const [displayCount, setDisplayCount] = useState<number>(0);
|
||||
// 페이지네이션 모드: 현재 페이지 (1부터 시작)
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음)
|
||||
const [loadingRowIdx, setLoadingRowIdx] = useState<number>(-1);
|
||||
|
||||
// 이벤트 버스
|
||||
const { publish, subscribe } = usePopEvent(screenId || "");
|
||||
|
||||
// 외부 필터 조건 (연결 시스템에서 수신, 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]);
|
||||
|
||||
// 카드 버튼 클릭 핸들러
|
||||
const handleCardButtonClick = useCallback(
|
||||
async (cell: CardCellDefinition, row: RowData) => {
|
||||
if (!cell.buttonAction) return;
|
||||
|
||||
// 확인 다이얼로그 (간단 구현: window.confirm)
|
||||
if (cell.buttonConfirm?.enabled) {
|
||||
const msg = cell.buttonConfirm.message || "이 작업을 실행하시겠습니까?";
|
||||
if (!window.confirm(msg)) return;
|
||||
}
|
||||
|
||||
const rowIndex = rows.indexOf(row);
|
||||
setLoadingRowIdx(rowIndex);
|
||||
|
||||
try {
|
||||
const result = await executePopAction(cell.buttonAction, row as Record<string, unknown>, {
|
||||
publish,
|
||||
screenId,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success("작업이 완료되었습니다.");
|
||||
} else {
|
||||
toast.error(result.error || "작업에 실패했습니다.");
|
||||
}
|
||||
} catch {
|
||||
toast.error("알 수 없는 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setLoadingRowIdx(-1);
|
||||
}
|
||||
},
|
||||
[rows, publish, screenId]
|
||||
);
|
||||
|
||||
// 행 클릭 핸들러 (selected_row 발행 + 모달 닫기 옵션)
|
||||
const handleRowClick = useCallback(
|
||||
(row: RowData) => {
|
||||
if (rowClickAction === "none") return;
|
||||
|
||||
// selected_row 이벤트 발행
|
||||
if (componentId) {
|
||||
publish(`__comp_output__${componentId}__selected_row`, row);
|
||||
}
|
||||
|
||||
// 모달 내부에서 사용 시: 선택 후 모달 닫기 + 데이터 반환
|
||||
if (rowClickAction === "select-and-close-modal") {
|
||||
publish("__pop_modal_close__", { selectedRow: row });
|
||||
}
|
||||
},
|
||||
[rowClickAction, componentId, publish]
|
||||
);
|
||||
|
||||
// 오버플로우 설정 (JSON 복원 시 string 유입 방어)
|
||||
const overflowMode = overflow?.mode || "loadMore";
|
||||
const visibleRows = Number(overflow?.visibleRows) || 5;
|
||||
const loadMoreCount = Number(overflow?.loadMoreCount) || 5;
|
||||
const maxExpandRows = Number(overflow?.maxExpandRows) || 50;
|
||||
const showExpandButton = overflow?.showExpandButton ?? true;
|
||||
const pageSize = Number(overflow?.pageSize) || visibleRows;
|
||||
const paginationStyle = overflow?.paginationStyle || "bottom";
|
||||
|
||||
// --- 외부 필터 적용 (복수 필터 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()));
|
||||
};
|
||||
|
||||
const allFilters = [...externalFilters.values()];
|
||||
return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f)));
|
||||
}, [rows, externalFilters]);
|
||||
|
||||
// --- 더보기 모드 ---
|
||||
useEffect(() => {
|
||||
setDisplayCount(visibleRows);
|
||||
}, [visibleRows]);
|
||||
|
||||
const effectiveLimit = Math.min(displayCount || visibleRows, maxExpandRows, filteredRows.length);
|
||||
const hasMore = showExpandButton && filteredRows.length > effectiveLimit && effectiveLimit < maxExpandRows;
|
||||
const isExpanded = effectiveLimit > visibleRows;
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setDisplayCount((prev) => {
|
||||
const current = prev || visibleRows;
|
||||
return Math.min(current + loadMoreCount, maxExpandRows, filteredRows.length);
|
||||
});
|
||||
}, [visibleRows, loadMoreCount, maxExpandRows, filteredRows.length]);
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
setDisplayCount(visibleRows);
|
||||
}, [visibleRows]);
|
||||
|
||||
// --- 페이지네이션 모드 ---
|
||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [pageSize, filteredRows.length]);
|
||||
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||||
}, [totalPages]);
|
||||
|
||||
// --- 모드별 visibleData 결정 ---
|
||||
const visibleData = useMemo(() => {
|
||||
if (overflowMode === "pagination") {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredRows.slice(start, start + pageSize);
|
||||
}
|
||||
return filteredRows.slice(0, effectiveLimit);
|
||||
}, [overflowMode, filteredRows, currentPage, pageSize, effectiveLimit]);
|
||||
|
||||
// dataSource 원시값 추출 (객체 참조 대신 안정적인 의존성 사용)
|
||||
const dsTableName = dataSource?.tableName;
|
||||
const dsSortColumn = dataSource?.sort?.column;
|
||||
const dsSortDirection = dataSource?.sort?.direction;
|
||||
const dsLimitMode = dataSource?.limit?.mode;
|
||||
const dsLimitCount = dataSource?.limit?.count;
|
||||
const dsFiltersKey = useMemo(
|
||||
() => JSON.stringify(dataSource?.filters || []),
|
||||
[dataSource?.filters]
|
||||
);
|
||||
|
||||
// 데이터 조회
|
||||
useEffect(() => {
|
||||
if (!dsTableName) {
|
||||
setLoading(false);
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 필터 조건 구성 (설정 패널 고정 필터 + 외부 검색 필터)
|
||||
const filters: Record<string, unknown> = {};
|
||||
const parsedFilters = JSON.parse(dsFiltersKey) as Array<{ column?: string; value?: string }>;
|
||||
if (parsedFilters.length > 0) {
|
||||
parsedFilters.forEach((f) => {
|
||||
if (f.column && f.value) {
|
||||
filters[f.column] = f.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 정렬 조건
|
||||
const sortBy = dsSortColumn;
|
||||
const sortOrder = dsSortDirection;
|
||||
|
||||
// 개수 제한 (string 유입 방어: Number 캐스팅)
|
||||
const size =
|
||||
dsLimitMode === "limited" && dsLimitCount
|
||||
? Number(dsLimitCount)
|
||||
: maxExpandRows;
|
||||
|
||||
const result = await dataApi.getTableData(dsTableName, {
|
||||
page: 1,
|
||||
size,
|
||||
sortBy: sortOrder ? sortBy : undefined,
|
||||
sortOrder,
|
||||
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
||||
});
|
||||
|
||||
setRows(result.data || []);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "데이터 조회 실패";
|
||||
setError(message);
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [dsTableName, dsSortColumn, dsSortDirection, dsLimitMode, dsLimitCount, dsFiltersKey, maxExpandRows]);
|
||||
|
||||
// 로딩 상태
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
<span className="text-xs text-destructive">{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 테이블 미선택
|
||||
if (!dataSource?.tableName) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
테이블을 선택하세요
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 없음
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
데이터가 없습니다
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPaginationSide = overflowMode === "pagination" && paginationStyle === "side" && totalPages > 1;
|
||||
|
||||
return (
|
||||
<div className={`flex w-full flex-col ${className || ""}`}>
|
||||
{/* 헤더 */}
|
||||
{header?.enabled && header.label && (
|
||||
<div className="shrink-0 border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">{header.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className={`flex-1 ${isPaginationSide ? "relative" : ""}`}>
|
||||
{displayMode === "list" ? (
|
||||
<ListModeView columns={listColumns} data={visibleData} onRowClick={rowClickAction !== "none" ? handleRowClick : undefined} />
|
||||
) : (
|
||||
<CardModeView
|
||||
cardGrid={cardGrid}
|
||||
data={visibleData}
|
||||
handleCardButtonClick={handleCardButtonClick}
|
||||
loadingRowId={loadingRowIdx}
|
||||
/>
|
||||
)}
|
||||
{isPaginationSide && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
className="absolute left-1 top-1/2 z-10 h-7 w-7 -translate-y-1/2 rounded-full bg-background/60 opacity-70 shadow-sm backdrop-blur-sm transition-opacity hover:opacity-100 disabled:opacity-20"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="absolute right-1 top-1/2 z-10 h-7 w-7 -translate-y-1/2 rounded-full bg-background/60 opacity-70 shadow-sm backdrop-blur-sm transition-opacity hover:opacity-100 disabled:opacity-20"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* side 모드 페이지 인디케이터 (컨텐츠 아래 별도 영역) */}
|
||||
{isPaginationSide && (
|
||||
<div className="shrink-0 flex justify-center py-1">
|
||||
<span className="rounded-full bg-muted/50 px-2.5 py-0.5 text-[10px] tabular-nums text-muted-foreground">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 더보기 모드 컨트롤 */}
|
||||
{overflowMode === "loadMore" && showExpandButton && (hasMore || isExpanded) && (
|
||||
<div className="shrink-0 border-t px-3 py-1.5 flex gap-2">
|
||||
{hasMore && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLoadMore}
|
||||
className="h-7 flex-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<ChevronDown className="mr-1 h-3 w-3" />
|
||||
더보기 ({rows.length - effectiveLimit}건 남음)
|
||||
</Button>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCollapse}
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
>
|
||||
<ChevronUp className="mr-1 h-3 w-3" />
|
||||
접기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 bottom 모드 컨트롤 */}
|
||||
{overflowMode === "pagination" && paginationStyle === "bottom" && totalPages > 1 && (
|
||||
<div className="shrink-0 border-t px-3 py-1.5 flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{rows.length}건 중 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, rows.length)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-xs tabular-nums px-1">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="h-6 w-6"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 리스트 모드 =====
|
||||
|
||||
interface ListModeViewProps {
|
||||
columns: ListColumnConfig[];
|
||||
data: RowData[];
|
||||
onRowClick?: (row: RowData) => void;
|
||||
}
|
||||
|
||||
function ListModeView({ columns, data, onRowClick }: ListModeViewProps) {
|
||||
// 런타임 컬럼 전환 상태
|
||||
// key: 컬럼 인덱스, value: 현재 활성 컬럼명 (alternateColumns 중 하나 또는 원래 columnName)
|
||||
const [activeColumns, setActiveColumns] = useState<Record<number, string>>({});
|
||||
|
||||
if (columns.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
컬럼을 설정하세요
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols = columns.map((c) => c.width || "1fr").join(" ");
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* 헤더 행 */}
|
||||
<div
|
||||
className="border-b bg-muted/50"
|
||||
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
||||
>
|
||||
{columns.map((col, colIdx) => {
|
||||
const hasAlternates = (col.alternateColumns || []).length > 0;
|
||||
const currentColName = activeColumns[colIdx] || col.columnName;
|
||||
// 원래 컬럼이면 기존 라벨, 전환된 컬럼이면 컬럼명 부분만 표시
|
||||
const currentLabel =
|
||||
currentColName === col.columnName
|
||||
? col.label
|
||||
: resolveColumnName(currentColName);
|
||||
|
||||
if (hasAlternates) {
|
||||
// 전환 가능한 헤더: Popover 드롭다운
|
||||
return (
|
||||
<Popover key={col.columnName}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex w-full items-center justify-between px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
style={{ textAlign: col.align || "left" }}
|
||||
>
|
||||
<span className="truncate">{currentLabel}</span>
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto min-w-[120px] p-1" align="start">
|
||||
<div className="flex flex-col">
|
||||
{/* 원래 컬럼 */}
|
||||
<button
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-left text-xs transition-colors",
|
||||
currentColName === col.columnName
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveColumns((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[colIdx];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{col.label} (기본)
|
||||
</button>
|
||||
{/* 대체 컬럼들 */}
|
||||
{(col.alternateColumns || []).map((altCol) => {
|
||||
const altLabel = resolveColumnName(altCol);
|
||||
return (
|
||||
<button
|
||||
key={altCol}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-left text-xs transition-colors",
|
||||
currentColName === altCol
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveColumns((prev) => ({
|
||||
...prev,
|
||||
[colIdx]: altCol,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{altLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// 전환 없는 일반 헤더
|
||||
return (
|
||||
<div
|
||||
key={col.columnName}
|
||||
className="px-2 py-1.5 text-xs font-medium text-muted-foreground"
|
||||
style={{ textAlign: col.align || "left" }}
|
||||
>
|
||||
{col.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 데이터 행 */}
|
||||
{data.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"border-b last:border-b-0 hover:bg-muted/30 transition-colors",
|
||||
onRowClick && "cursor-pointer"
|
||||
)}
|
||||
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{columns.map((col, colIdx) => {
|
||||
const currentColName = activeColumns[colIdx] || col.columnName;
|
||||
const resolvedKey = resolveColumnName(currentColName);
|
||||
return (
|
||||
<div
|
||||
key={`${col.columnName}-${colIdx}`}
|
||||
className="px-2 py-1.5 text-xs truncate"
|
||||
style={{ textAlign: col.align || "left" }}
|
||||
>
|
||||
{String(row[resolvedKey] ?? "")}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 카드 모드 =====
|
||||
|
||||
interface CardModeViewProps {
|
||||
cardGrid?: CardGridConfig;
|
||||
data: RowData[];
|
||||
handleCardButtonClick?: (cell: CardCellDefinition, row: RowData) => void;
|
||||
loadingRowId?: number;
|
||||
}
|
||||
|
||||
function CardModeView({ cardGrid, data, handleCardButtonClick, loadingRowId }: CardModeViewProps) {
|
||||
if (!cardGrid || (cardGrid.cells || []).length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
카드 레이아웃을 설정하세요
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 p-2">
|
||||
{data.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-md border"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns:
|
||||
cardGrid.colWidths && cardGrid.colWidths.length > 0
|
||||
? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ")
|
||||
: "1fr",
|
||||
gridTemplateRows:
|
||||
cardGrid.rowHeights && cardGrid.rowHeights.length > 0
|
||||
? cardGrid.rowHeights
|
||||
.map((h) => {
|
||||
if (!h) return "minmax(32px, auto)";
|
||||
// px 값 -> minmax(Npx, auto): 최소 높이 보장 + 컨텐츠에 맞게 확장
|
||||
if (h.endsWith("px")) {
|
||||
return `minmax(${h}, auto)`;
|
||||
}
|
||||
// fr 값 -> 마이그레이션 호환: px 변환 후 minmax 적용
|
||||
const px = Math.round(parseFloat(h) * 32) || 32;
|
||||
return `minmax(${px}px, auto)`;
|
||||
})
|
||||
.join(" ")
|
||||
: `repeat(${Number(cardGrid.rows) || 1}, minmax(32px, auto))`,
|
||||
gap: `${Number(cardGrid.gap) || 0}px`,
|
||||
}}
|
||||
>
|
||||
{(cardGrid.cells || []).map((cell) => {
|
||||
// 가로 정렬 매핑
|
||||
const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const;
|
||||
const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const;
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className="overflow-hidden p-1.5"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: alignItemsMap[cell.verticalAlign || "top"],
|
||||
alignItems: justifyMap[cell.align || "left"],
|
||||
gridColumn: `${Number(cell.col) || 1} / span ${Number(cell.colSpan) || 1}`,
|
||||
gridRow: `${Number(cell.row) || 1} / span ${Number(cell.rowSpan) || 1}`,
|
||||
border: cardGrid.showBorder
|
||||
? "1px solid hsl(var(--border))"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
{renderCellContent(cell, row, handleCardButtonClick, loadingRowId === i)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 셀 컨텐츠 렌더링 =====
|
||||
|
||||
function renderCellContent(
|
||||
cell: CardCellDefinition,
|
||||
row: RowData,
|
||||
onButtonClick?: (cell: CardCellDefinition, row: RowData) => void,
|
||||
isButtonLoading?: boolean,
|
||||
): React.ReactNode {
|
||||
const value = row[cell.columnName];
|
||||
const displayValue = value != null ? String(value) : "";
|
||||
|
||||
switch (cell.type) {
|
||||
case "image":
|
||||
return displayValue ? (
|
||||
<img
|
||||
src={displayValue}
|
||||
alt={cell.label || cell.columnName}
|
||||
className="h-full max-h-[200px] w-full object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted rounded">
|
||||
<span className="text-[10px] text-muted-foreground">No Image</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "badge":
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
{displayValue}
|
||||
</span>
|
||||
);
|
||||
|
||||
case "button":
|
||||
return (
|
||||
<Button
|
||||
variant={cell.buttonVariant || "outline"}
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
disabled={isButtonLoading}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onButtonClick?.(cell, row);
|
||||
}}
|
||||
>
|
||||
{cell.label || displayValue}
|
||||
</Button>
|
||||
);
|
||||
|
||||
case "text":
|
||||
default: {
|
||||
// 글자 크기 매핑
|
||||
const fontSizeClass =
|
||||
cell.fontSize === "sm"
|
||||
? "text-[10px]"
|
||||
: cell.fontSize === "lg"
|
||||
? "text-sm"
|
||||
: "text-xs"; // md (기본)
|
||||
const isLabelLeft = cell.labelPosition === "left";
|
||||
|
||||
return (
|
||||
<div className={isLabelLeft ? "flex items-baseline gap-1" : "flex flex-col"}>
|
||||
{cell.label && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{cell.label}{isLabelLeft ? ":" : ""}
|
||||
</span>
|
||||
)}
|
||||
<span className={`${fontSizeClass} truncate`}>{displayValue}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,176 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* pop-string-list 디자이너 미리보기
|
||||
*
|
||||
* 디자인 모드에서 캔버스에 표시되는 간략한 미리보기.
|
||||
* 실제 데이터는 가져오지 않고 더미 데이터로 레이아웃만 시각화.
|
||||
*/
|
||||
|
||||
import type { PopStringListConfig } from "./types";
|
||||
|
||||
interface PopStringListPreviewProps {
|
||||
config?: PopStringListConfig;
|
||||
}
|
||||
|
||||
export function PopStringListPreviewComponent({
|
||||
config,
|
||||
}: PopStringListPreviewProps) {
|
||||
const displayMode = config?.displayMode || "list";
|
||||
const header = config?.header;
|
||||
const tableName = config?.dataSource?.tableName;
|
||||
const listColumns = config?.listColumns || [];
|
||||
const cardGrid = config?.cardGrid;
|
||||
|
||||
// 테이블 미선택
|
||||
if (!tableName) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
테이블을 선택하세요
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
{header?.enabled && (
|
||||
<div className="shrink-0 border-b px-2 py-1">
|
||||
<span className="text-[10px] font-medium">
|
||||
{header.label || "리스트 목록"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모드별 미리보기 */}
|
||||
<div className="flex-1 overflow-hidden p-1">
|
||||
{displayMode === "list" ? (
|
||||
<ListPreview columns={listColumns} />
|
||||
) : (
|
||||
<CardPreview cardGrid={cardGrid} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 모드 라벨 */}
|
||||
<div className="shrink-0 border-t px-2 py-0.5">
|
||||
<span className="text-[8px] text-muted-foreground">
|
||||
{displayMode === "list" ? "리스트" : "카드"} | {tableName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 리스트 미리보기 =====
|
||||
|
||||
function ListPreview({
|
||||
columns,
|
||||
}: {
|
||||
columns: PopStringListConfig["listColumns"];
|
||||
}) {
|
||||
const cols = columns || [];
|
||||
|
||||
if (cols.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<span className="text-[8px] text-muted-foreground">컬럼 미설정</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols = cols.map((c) => c.width || "1fr").join(" ");
|
||||
const dummyRows = 3;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="border-b bg-muted/50"
|
||||
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
||||
>
|
||||
{cols.map((col) => (
|
||||
<div
|
||||
key={col.columnName}
|
||||
className="px-1 py-0.5 text-[8px] font-medium text-muted-foreground truncate"
|
||||
>
|
||||
{col.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 더미 행 */}
|
||||
{Array.from({ length: dummyRows }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-b last:border-b-0"
|
||||
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
||||
>
|
||||
{cols.map((col) => (
|
||||
<div
|
||||
key={col.columnName}
|
||||
className="px-1 py-0.5"
|
||||
>
|
||||
<div className="h-2 w-3/4 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 카드 미리보기 =====
|
||||
|
||||
function CardPreview({
|
||||
cardGrid,
|
||||
}: {
|
||||
cardGrid: PopStringListConfig["cardGrid"];
|
||||
}) {
|
||||
if (!cardGrid || cardGrid.cells.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<span className="text-[8px] text-muted-foreground">
|
||||
카드 레이아웃 미설정
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 더미 카드 2장
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{[0, 1].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded border"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: cardGrid.colWidths.join(" "),
|
||||
gridTemplateRows:
|
||||
cardGrid.rowHeights?.join(" ") ||
|
||||
`repeat(${cardGrid.rows}, 1fr)`,
|
||||
gap: `${cardGrid.gap}px`,
|
||||
minHeight: "24px",
|
||||
}}
|
||||
>
|
||||
{cardGrid.cells.map((cell) => (
|
||||
<div
|
||||
key={cell.id}
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
gridColumn: `${cell.col} / span ${cell.colSpan}`,
|
||||
gridRow: `${cell.row} / span ${cell.rowSpan}`,
|
||||
border: cardGrid.showBorder
|
||||
? "1px dashed hsl(var(--border))"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<div className="h-2 w-3/4 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* pop-string-list 컴포넌트 레지스트리 등록 진입점
|
||||
*
|
||||
* 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨
|
||||
*/
|
||||
|
||||
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||
import { PopStringListComponent } from "./PopStringListComponent";
|
||||
import { PopStringListConfigPanel } from "./PopStringListConfig";
|
||||
import { PopStringListPreviewComponent } from "./PopStringListPreview";
|
||||
import type { PopStringListConfig } from "./types";
|
||||
|
||||
// 기본 설정값
|
||||
const defaultConfig: PopStringListConfig = {
|
||||
displayMode: "list",
|
||||
header: { enabled: true, label: "" },
|
||||
overflow: { visibleRows: 5, mode: "loadMore", showExpandButton: true, loadMoreCount: 5, maxExpandRows: 50, pageSize: 5, paginationStyle: "bottom" },
|
||||
dataSource: { tableName: "" },
|
||||
listColumns: [],
|
||||
cardGrid: undefined,
|
||||
};
|
||||
|
||||
// 레지스트리 등록
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-string-list",
|
||||
name: "리스트 목록",
|
||||
description: "테이블 데이터를 리스트 또는 카드 형태로 표시",
|
||||
category: "display",
|
||||
icon: "List",
|
||||
component: PopStringListComponent,
|
||||
configPanel: PopStringListConfigPanel,
|
||||
preview: PopStringListPreviewComponent,
|
||||
defaultProps: defaultConfig,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 행 데이터를 전달" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" },
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
// ===== pop-string-list 전용 타입 =====
|
||||
// pop-card-list와 완전 독립. 공유하는 것은 CardListDataSource 타입만 import.
|
||||
|
||||
import type { CardListDataSource } from "../types";
|
||||
import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "../pop-button";
|
||||
|
||||
/** 표시 모드 */
|
||||
export type StringListDisplayMode = "list" | "card";
|
||||
|
||||
/** 행 클릭 동작 */
|
||||
export type RowClickAction = "none" | "publish" | "select-and-close-modal";
|
||||
|
||||
/** 카드 내부 셀 1개 정의 */
|
||||
export interface CardCellDefinition {
|
||||
id: string;
|
||||
row: number; // 1부터
|
||||
col: number; // 1부터
|
||||
rowSpan: number; // 행 병합 (기본 1)
|
||||
colSpan: number; // 열 병합 (기본 1)
|
||||
columnName: string; // 바인딩할 DB 컬럼명
|
||||
label?: string; // 셀 위에 표시할 라벨 (선택)
|
||||
labelPosition?: "top" | "left"; // 라벨 위치 (기본 top)
|
||||
type: "text" | "image" | "badge" | "button"; // 셀 렌더링 타입
|
||||
fontSize?: "sm" | "md" | "lg"; // 글자 크기: 작게(10px) / 보통(12px) / 크게(14px)
|
||||
align?: "left" | "center" | "right"; // 가로 정렬 (기본 left)
|
||||
verticalAlign?: "top" | "middle" | "bottom"; // 세로 정렬 (기본 top)
|
||||
// button 타입 전용 (pop-button 로직 재사용)
|
||||
buttonAction?: ButtonMainAction; // 클릭 시 실행할 액션
|
||||
buttonVariant?: ButtonVariant; // 버튼 스타일
|
||||
buttonConfirm?: ConfirmConfig; // 확인 다이얼로그 설정
|
||||
}
|
||||
|
||||
/** 카드 그리드 레이아웃 설정 */
|
||||
export interface CardGridConfig {
|
||||
rows: number; // 행 수
|
||||
cols: number; // 열 수
|
||||
colWidths: string[]; // fr 단위 배열 (예: ["2fr", "1fr", "1fr"])
|
||||
rowHeights?: string[]; // px 단위 배열 (예: ["32px", "48px"], 기본 32px 균등)
|
||||
gap: number; // 셀 간격 (px, 2~8 권장)
|
||||
showBorder: boolean; // 셀 보더 표시
|
||||
cells: CardCellDefinition[]; // 셀 목록
|
||||
}
|
||||
|
||||
/** 리스트 모드 컬럼 1개 설정 */
|
||||
export interface ListColumnConfig {
|
||||
columnName: string; // DB 컬럼명
|
||||
label: string; // 헤더 라벨
|
||||
width?: string; // fr 단위 (기본 "1fr")
|
||||
align?: "left" | "center" | "right"; // 정렬
|
||||
alternateColumns?: string[]; // 런타임에서 전환 가능한 대체 컬럼 목록
|
||||
}
|
||||
|
||||
/** 오버플로우 방식 */
|
||||
export type OverflowMode = "loadMore" | "pagination";
|
||||
|
||||
/** 페이지네이션 네비게이션 스타일 */
|
||||
export type PaginationStyle = "bottom" | "side";
|
||||
|
||||
/** 오버플로우 설정 */
|
||||
export interface OverflowConfig {
|
||||
visibleRows: number; // 기본 표시 행 수
|
||||
mode: OverflowMode; // 오버플로우 방식
|
||||
// 더보기 모드 전용
|
||||
showExpandButton: boolean; // "더보기" 버튼 표시
|
||||
loadMoreCount: number; // 더보기 클릭 시 추가 로딩 행 수
|
||||
maxExpandRows: number; // 최대 표시 행 수 (무한 확장 방지)
|
||||
// 페이지네이션 모드 전용
|
||||
pageSize: number; // 페이지당 표시 행 수
|
||||
paginationStyle: PaginationStyle; // bottom: 하단 페이지 표시, side: 좌우 화살표
|
||||
}
|
||||
|
||||
/** 헤더 설정 */
|
||||
export interface StringListHeaderConfig {
|
||||
enabled: boolean; // 헤더 표시 여부
|
||||
label?: string; // 헤더 라벨 텍스트
|
||||
}
|
||||
|
||||
/** pop-string-list 전체 설정 */
|
||||
export interface PopStringListConfig {
|
||||
displayMode: StringListDisplayMode;
|
||||
header: StringListHeaderConfig;
|
||||
overflow: OverflowConfig;
|
||||
dataSource: CardListDataSource; // 기존 타입 재활용
|
||||
selectedColumns?: string[]; // 사용자가 선택한 컬럼명 목록 (모드 무관 영속)
|
||||
listColumns?: ListColumnConfig[]; // 리스트 모드 전용
|
||||
cardGrid?: CardGridConfig; // 카드 모드 전용
|
||||
rowClickAction?: RowClickAction; // 행 클릭 시 동작 (기본: "none")
|
||||
}
|
||||
|
|
@ -265,15 +265,18 @@ function DateTimePreview({ config }: { config?: PopTextConfig }) {
|
|||
);
|
||||
}
|
||||
|
||||
// 시간/날짜 (실시간 지원)
|
||||
// 시간/날짜 (항상 실시간)
|
||||
function DateTimeDisplay({ config }: { config?: PopTextConfig }) {
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
// isRealtime 기본값: true (설정 패널 UI와 일치)
|
||||
const isRealtime = config?.isRealtime ?? true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.isRealtime) return;
|
||||
if (!isRealtime) return;
|
||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [config?.isRealtime]);
|
||||
}, [isRealtime]);
|
||||
|
||||
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
|
||||
const dateFormat = config?.dateTimeConfig
|
||||
|
|
@ -391,6 +394,19 @@ interface PopTextConfigPanelProps {
|
|||
onUpdate: (config: PopTextConfig) => void;
|
||||
}
|
||||
|
||||
// 섹션 구분선 컴포넌트
|
||||
function SectionDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="pt-3 pb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-gray-300" />
|
||||
<span className="text-xs font-medium text-gray-500">{label}</span>
|
||||
<div className="h-px flex-1 bg-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopTextConfigPanel({
|
||||
config,
|
||||
onUpdate,
|
||||
|
|
@ -398,10 +414,10 @@ export function PopTextConfigPanel({
|
|||
const textType = config?.textType || "text";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 overflow-y-auto pr-1 pb-32">
|
||||
{/* 텍스트 타입 선택 */}
|
||||
<SectionDivider label="텍스트 타입" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">텍스트 타입</Label>
|
||||
<Select
|
||||
value={textType}
|
||||
onValueChange={(v) =>
|
||||
|
|
@ -424,8 +440,8 @@ export function PopTextConfigPanel({
|
|||
{/* 서브타입별 설정 */}
|
||||
{textType === "text" && (
|
||||
<>
|
||||
<SectionDivider label="내용 설정" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">내용</Label>
|
||||
<Textarea
|
||||
value={config?.content || ""}
|
||||
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
|
||||
|
|
@ -434,6 +450,7 @@ export function PopTextConfigPanel({
|
|||
className="text-xs resize-none"
|
||||
/>
|
||||
</div>
|
||||
<SectionDivider label="스타일 설정" />
|
||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
</>
|
||||
|
|
@ -441,18 +458,11 @@ export function PopTextConfigPanel({
|
|||
|
||||
{textType === "datetime" && (
|
||||
<>
|
||||
<SectionDivider label="시간/날짜 설정" />
|
||||
{/* 포맷 빌더 UI */}
|
||||
<DateTimeFormatBuilder config={config} onUpdate={onUpdate} />
|
||||
|
||||
{/* 실시간 업데이트 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={config?.isRealtime ?? true}
|
||||
onCheckedChange={(v) => onUpdate({ ...config, isRealtime: v })}
|
||||
/>
|
||||
<Label className="text-xs">실시간 업데이트</Label>
|
||||
</div>
|
||||
|
||||
<SectionDivider label="스타일 설정" />
|
||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
</>
|
||||
|
|
@ -460,6 +470,7 @@ export function PopTextConfigPanel({
|
|||
|
||||
{textType === "image" && (
|
||||
<>
|
||||
<SectionDivider label="이미지 설정" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">이미지 URL</Label>
|
||||
<Input
|
||||
|
|
@ -507,14 +518,15 @@ export function PopTextConfigPanel({
|
|||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<SectionDivider label="정렬 설정" />
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{textType === "title" && (
|
||||
<>
|
||||
<SectionDivider label="제목 설정" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">제목 텍스트</Label>
|
||||
<Input
|
||||
value={config?.content || ""}
|
||||
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
|
||||
|
|
@ -522,6 +534,7 @@ export function PopTextConfigPanel({
|
|||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<SectionDivider label="스타일 설정" />
|
||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||
<FontWeightSelect config={config} onUpdate={onUpdate} />
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export const FONT_SIZE_CLASSES: Record<FontSize, string> = {
|
|||
xl: "text-[64px]",
|
||||
};
|
||||
|
||||
|
||||
export const FONT_WEIGHT_CLASSES: Record<FontWeight, string> = {
|
||||
normal: "font-normal",
|
||||
medium: "font-medium",
|
||||
|
|
@ -86,3 +87,477 @@ export const JUSTIFY_CLASSES: Record<string, string> = {
|
|||
center: "justify-center",
|
||||
right: "justify-end",
|
||||
};
|
||||
|
||||
// =============================================
|
||||
// Phase 0 공통 타입 (모든 POP 컴포넌트 공용)
|
||||
// =============================================
|
||||
|
||||
// ----- 컬럼 바인딩: 컬럼별 읽기/쓰기 제어 -----
|
||||
|
||||
export type ColumnMode = "read" | "write" | "readwrite" | "hidden";
|
||||
|
||||
export interface ColumnBinding {
|
||||
columnName: string;
|
||||
sourceTable?: string;
|
||||
mode: ColumnMode;
|
||||
label?: string;
|
||||
defaultValue?: unknown;
|
||||
}
|
||||
|
||||
// ----- 조인 설정: 테이블 간 관계 정의 -----
|
||||
|
||||
export type JoinType = "inner" | "left" | "right";
|
||||
|
||||
export interface JoinConfig {
|
||||
targetTable: string;
|
||||
joinType: JoinType;
|
||||
on: {
|
||||
sourceColumn: string;
|
||||
targetColumn: string;
|
||||
};
|
||||
columns?: string[];
|
||||
}
|
||||
|
||||
// ----- 데이터 소스: 테이블 조회/집계 통합 설정 -----
|
||||
|
||||
export type AggregationType = "count" | "sum" | "avg" | "min" | "max";
|
||||
export type FilterOperator =
|
||||
| "="
|
||||
| "!="
|
||||
| ">"
|
||||
| ">="
|
||||
| "<"
|
||||
| "<="
|
||||
| "like"
|
||||
| "in"
|
||||
| "between";
|
||||
|
||||
export interface DataSourceFilter {
|
||||
column: string;
|
||||
operator: FilterOperator;
|
||||
value: unknown; // between이면 [from, to]
|
||||
}
|
||||
|
||||
export interface SortConfig {
|
||||
column: string;
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
export interface DataSourceConfig {
|
||||
tableName: string;
|
||||
columns?: ColumnBinding[];
|
||||
filters?: DataSourceFilter[];
|
||||
sort?: SortConfig[];
|
||||
aggregation?: {
|
||||
type: AggregationType;
|
||||
column: string;
|
||||
groupBy?: string[];
|
||||
};
|
||||
joins?: JoinConfig[];
|
||||
refreshInterval?: number; // 초 단위, 0이면 비활성
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ----- 액션 설정: 버튼/링크 클릭 시 동작 정의 -----
|
||||
|
||||
export interface PopActionConfig {
|
||||
type:
|
||||
| "navigate"
|
||||
| "modal"
|
||||
| "save"
|
||||
| "delete"
|
||||
| "api"
|
||||
| "event"
|
||||
| "refresh";
|
||||
// navigate
|
||||
targetScreenId?: string;
|
||||
params?: Record<string, string>;
|
||||
// modal
|
||||
modalScreenId?: string;
|
||||
modalTitle?: string;
|
||||
// save/delete
|
||||
targetTable?: string;
|
||||
confirmMessage?: string;
|
||||
// api
|
||||
apiEndpoint?: string;
|
||||
apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
// event
|
||||
eventName?: string;
|
||||
eventPayload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// pop-dashboard 전용 타입
|
||||
// =============================================
|
||||
|
||||
// ----- 표시 모드 / 서브타입 -----
|
||||
|
||||
export type DashboardDisplayMode =
|
||||
| "arrows"
|
||||
| "auto-slide"
|
||||
| "scroll";
|
||||
export type DashboardSubType = "kpi-card" | "chart" | "gauge" | "stat-card";
|
||||
export type FormulaDisplayFormat = "value" | "fraction" | "percent" | "ratio";
|
||||
export type ChartType = "bar" | "pie" | "line";
|
||||
export type TrendPeriod = "daily" | "weekly" | "monthly";
|
||||
|
||||
// ----- 색상 구간 -----
|
||||
|
||||
export interface ColorRange {
|
||||
min: number;
|
||||
max: number;
|
||||
color: string; // hex 또는 Tailwind 색상
|
||||
}
|
||||
|
||||
// ----- 수식(계산식) 설정 -----
|
||||
|
||||
export interface FormulaValue {
|
||||
id: string; // "A", "B" 등
|
||||
dataSource: DataSourceConfig;
|
||||
label: string; // "생산량", "총재고량"
|
||||
}
|
||||
|
||||
export interface FormulaConfig {
|
||||
enabled: boolean;
|
||||
values: FormulaValue[];
|
||||
expression: string; // "A / B", "A + B", "A / B * 100"
|
||||
displayFormat: FormulaDisplayFormat;
|
||||
}
|
||||
|
||||
// ----- 아이템 내 요소별 보이기/숨기기 -----
|
||||
|
||||
export interface ItemVisibility {
|
||||
showLabel: boolean;
|
||||
showValue: boolean;
|
||||
showUnit: boolean;
|
||||
showTrend: boolean;
|
||||
showSubLabel: boolean;
|
||||
showTarget: boolean;
|
||||
}
|
||||
|
||||
// ----- 서브타입별 설정 -----
|
||||
|
||||
export interface KpiCardConfig {
|
||||
unit?: string; // "EA", "톤", "원"
|
||||
colorRanges?: ColorRange[];
|
||||
showTrend?: boolean;
|
||||
trendPeriod?: TrendPeriod;
|
||||
}
|
||||
|
||||
export interface ChartItemConfig {
|
||||
chartType: ChartType;
|
||||
xAxisColumn?: string;
|
||||
yAxisColumn?: string;
|
||||
colors?: string[];
|
||||
}
|
||||
|
||||
export interface GaugeConfig {
|
||||
min: number;
|
||||
max: number;
|
||||
target?: number; // 고정 목표값
|
||||
targetDataSource?: DataSourceConfig; // 동적 목표값
|
||||
colorRanges?: ColorRange[];
|
||||
}
|
||||
|
||||
export interface StatCategory {
|
||||
label: string; // "대기", "진행", "완료"
|
||||
filter: DataSourceFilter;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface StatCardConfig {
|
||||
categories: StatCategory[];
|
||||
showLink?: boolean;
|
||||
linkAction?: PopActionConfig;
|
||||
}
|
||||
|
||||
// ----- 그리드 모드 셀 (엑셀형 분할/병합) -----
|
||||
|
||||
export interface DashboardCell {
|
||||
id: string;
|
||||
gridColumn: string; // CSS Grid 값: "1 / 3"
|
||||
gridRow: string; // CSS Grid 값: "1 / 2"
|
||||
itemId: string | null; // null이면 빈 셀
|
||||
}
|
||||
|
||||
// ----- 대시보드 페이지(슬라이드) -----
|
||||
|
||||
/** 대시보드 한 페이지(슬라이드) - 독립적인 그리드 레이아웃 보유 */
|
||||
export interface DashboardPage {
|
||||
id: string;
|
||||
label?: string; // 디자이너에서 표시할 라벨 (예: "페이지 1")
|
||||
gridColumns: number; // 이 페이지의 열 수
|
||||
gridRows: number; // 이 페이지의 행 수
|
||||
gridCells: DashboardCell[]; // 이 페이지의 셀 배치 (각 셀에 itemId 지정)
|
||||
}
|
||||
|
||||
// ----- 대시보드 아이템 스타일 설정 -----
|
||||
|
||||
export interface ItemStyleConfig {
|
||||
/** 라벨 텍스트 정렬 (기본: center) */
|
||||
labelAlign?: TextAlign;
|
||||
}
|
||||
|
||||
// ----- 대시보드 아이템 -----
|
||||
|
||||
export interface DashboardItem {
|
||||
id: string;
|
||||
label: string; // pop-system 보이기/숨기기용
|
||||
visible: boolean;
|
||||
subType: DashboardSubType;
|
||||
dataSource: DataSourceConfig;
|
||||
|
||||
// 요소별 보이기/숨기기
|
||||
visibility: ItemVisibility;
|
||||
|
||||
// 계산식 (선택사항)
|
||||
formula?: FormulaConfig;
|
||||
|
||||
// 서브타입별 설정 (subType에 따라 하나만 사용)
|
||||
kpiConfig?: KpiCardConfig;
|
||||
chartConfig?: ChartItemConfig;
|
||||
gaugeConfig?: GaugeConfig;
|
||||
statConfig?: StatCardConfig;
|
||||
|
||||
/** 스타일 설정 (정렬, 글자 크기 3그룹) */
|
||||
itemStyle?: ItemStyleConfig;
|
||||
}
|
||||
|
||||
// ----- 대시보드 전체 설정 -----
|
||||
|
||||
export interface PopDashboardConfig {
|
||||
items: DashboardItem[];
|
||||
pages?: DashboardPage[]; // 페이지 배열 (각 페이지가 독립 그리드 레이아웃)
|
||||
displayMode: DashboardDisplayMode; // 페이지 간 전환 방식
|
||||
|
||||
// 모드별 설정
|
||||
autoSlideInterval?: number; // 초 (기본 5)
|
||||
autoSlideResumeDelay?: number; // 터치 후 재개 대기 초 (기본 3)
|
||||
|
||||
// 공통 스타일
|
||||
showIndicator?: boolean; // 페이지 인디케이터
|
||||
gap?: number; // 아이템 간 간격 px
|
||||
backgroundColor?: string;
|
||||
|
||||
// 데이터 소스 (아이템 공통)
|
||||
dataSource?: DataSourceConfig;
|
||||
}
|
||||
|
||||
|
||||
// ----- 조인 설정 -----
|
||||
|
||||
export interface CardColumnJoin {
|
||||
targetTable: string;
|
||||
joinType: "LEFT" | "INNER" | "RIGHT";
|
||||
sourceColumn: string; // 메인 테이블 컬럼
|
||||
targetColumn: string; // 조인 테이블 컬럼
|
||||
selectedTargetColumns?: string[]; // 가져올 대상 테이블 컬럼 목록
|
||||
}
|
||||
|
||||
// ----- 필터 설정 -----
|
||||
|
||||
export interface CardColumnFilter {
|
||||
column: string;
|
||||
operator: FilterOperator;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// ----- 본문 필드 바인딩 (라벨-값 쌍) -----
|
||||
|
||||
export interface CardFieldBinding {
|
||||
id: string;
|
||||
columnName: string; // DB 컬럼명
|
||||
label: string; // 표시 라벨 (예: "발주일")
|
||||
textColor?: string; // 텍스트 색상 (예: "#ef4444" 빨간색)
|
||||
}
|
||||
|
||||
// ----- 카드 헤더 설정 (코드 + 제목) -----
|
||||
|
||||
export interface CardHeaderConfig {
|
||||
codeField?: string; // 코드로 표시할 컬럼 선택
|
||||
titleField?: string; // 제목으로 표시할 컬럼 선택
|
||||
}
|
||||
|
||||
// ----- 카드 이미지 설정 -----
|
||||
|
||||
// 기본 이미지 URL (박스 아이콘)
|
||||
export const DEFAULT_CARD_IMAGE =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='1.5'%3E%3Cpath d='M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4'/%3E%3C/svg%3E";
|
||||
|
||||
export interface CardImageConfig {
|
||||
enabled: boolean;
|
||||
imageColumn?: string; // 이미지 URL 컬럼 (선택)
|
||||
defaultImage: string; // 기본 이미지 (자동 설정, 필수)
|
||||
}
|
||||
|
||||
// ----- 카드 본문 설정 -----
|
||||
|
||||
export interface CardBodyConfig {
|
||||
fields: CardFieldBinding[]; // 라벨-값 쌍 목록
|
||||
}
|
||||
|
||||
// ----- 카드 템플릿 (헤더 + 이미지 + 본문) -----
|
||||
|
||||
export interface CardTemplateConfig {
|
||||
header: CardHeaderConfig;
|
||||
image: CardImageConfig;
|
||||
body: CardBodyConfig;
|
||||
}
|
||||
|
||||
// ----- 데이터 소스 (테이블 단위) -----
|
||||
|
||||
export interface CardListDataSource {
|
||||
tableName: string;
|
||||
joins?: CardColumnJoin[];
|
||||
filters?: CardColumnFilter[];
|
||||
sort?: { column: string; direction: "asc" | "desc" };
|
||||
limit?: { mode: "all" | "limited"; count?: number };
|
||||
}
|
||||
|
||||
// ----- 카드 크기 -----
|
||||
|
||||
export type CardSize = "small" | "medium" | "large";
|
||||
|
||||
export const CARD_SIZE_LABELS: Record<CardSize, string> = {
|
||||
small: "작게",
|
||||
medium: "보통",
|
||||
large: "크게",
|
||||
};
|
||||
|
||||
// ----- 카드 스크롤 방향 -----
|
||||
|
||||
export type CardScrollDirection = "horizontal" | "vertical";
|
||||
|
||||
export const CARD_SCROLL_DIRECTION_LABELS: Record<CardScrollDirection, string> = {
|
||||
horizontal: "가로 스크롤",
|
||||
vertical: "세로 스크롤",
|
||||
};
|
||||
|
||||
// ----- 카드 내 입력 필드 설정 -----
|
||||
|
||||
export interface CardInputFieldConfig {
|
||||
enabled: boolean;
|
||||
columnName?: string; // 입력값이 저장될 컬럼
|
||||
label?: string; // 표시 라벨 (예: "발주 수량")
|
||||
unit?: string; // 단위 (예: "EA", "개")
|
||||
defaultValue?: number; // 기본값
|
||||
min?: number; // 최소값
|
||||
max?: number; // 최대값
|
||||
maxColumn?: string; // 최대값을 DB 컬럼에서 동적으로 가져올 컬럼명 (설정 시 row[maxColumn] 우선)
|
||||
step?: number; // 증감 단위
|
||||
}
|
||||
|
||||
// ----- 카드 내 계산 필드 설정 -----
|
||||
|
||||
export interface CardCalculatedFieldConfig {
|
||||
enabled: boolean;
|
||||
label?: string; // 표시 라벨 (예: "미입고")
|
||||
formula: string; // 계산식 (예: "order_qty - inbound_qty")
|
||||
sourceColumns: string[]; // 계산에 사용되는 컬럼들
|
||||
resultColumn?: string; // 결과를 저장할 컬럼 (선택)
|
||||
unit?: string; // 단위 (예: "EA")
|
||||
}
|
||||
|
||||
// ----- 담기 버튼 데이터 구조 (추후 API 연동 시 이 shape 그대로 사용) -----
|
||||
|
||||
export interface CartItem {
|
||||
row: Record<string, unknown>; // 카드 원본 행 데이터
|
||||
quantity: number; // 입력 수량
|
||||
packageUnit?: string; // 포장 단위 (box/bag/pack/bundle/roll/barrel)
|
||||
}
|
||||
|
||||
// ----- 담기 버튼 액션 설정 (pop-icon 스타일 + 장바구니 연동) -----
|
||||
|
||||
export interface CardCartActionConfig {
|
||||
navigateMode: "none" | "screen"; // 담기 후 이동 모드
|
||||
targetScreenId?: string; // 장바구니 POP 화면 ID (screen 모드)
|
||||
iconType?: "lucide" | "emoji"; // 아이콘 타입
|
||||
iconValue?: string; // Lucide 아이콘명 또는 이모지 값
|
||||
label?: string; // 담기 라벨 (기본: "담기")
|
||||
cancelLabel?: string; // 취소 라벨 (기본: "취소")
|
||||
}
|
||||
|
||||
// ----- pop-card-list 전체 설정 -----
|
||||
|
||||
// ----- 카드 프리셋별 고정 규격 -----
|
||||
// 각 프리셋은 카드 내용이 잘리지 않도록 계산된 고정 크기
|
||||
// 구성: 헤더(코드+제목) + 본문(이미지+필드 3개) + 입력/계산 필드 + 패딩
|
||||
export interface CardPresetSpec {
|
||||
height: number; // 카드 고정 높이 (px)
|
||||
imageSize: number; // 이미지 크기 (px)
|
||||
padding: number; // 내부 여백 (px)
|
||||
gap: number; // 요소 간격 (px)
|
||||
headerPadY: number; // 헤더 상하 패딩 (px)
|
||||
headerPadX: number; // 헤더 좌우 패딩 (px)
|
||||
codeText: number; // 코드 폰트 크기 (px)
|
||||
titleText: number; // 제목 폰트 크기 (px)
|
||||
bodyText: number; // 본문 폰트 크기 (px)
|
||||
}
|
||||
|
||||
export const CARD_PRESET_SPECS: Record<CardSize, CardPresetSpec> = {
|
||||
// 작게: 컴팩트 - 헤더(20) + 본문(이미지36+필드3x14=42) + 패딩(6*3) = ~80px
|
||||
small: {
|
||||
height: 88,
|
||||
imageSize: 36,
|
||||
padding: 6,
|
||||
gap: 4,
|
||||
headerPadY: 3,
|
||||
headerPadX: 6,
|
||||
codeText: 9,
|
||||
titleText: 11,
|
||||
bodyText: 10,
|
||||
},
|
||||
// 보통: 기본 - 헤더(26) + 본문(이미지48+필드3x16=48) + 패딩(8*3) = ~122px
|
||||
medium: {
|
||||
height: 120,
|
||||
imageSize: 48,
|
||||
padding: 8,
|
||||
gap: 6,
|
||||
headerPadY: 4,
|
||||
headerPadX: 8,
|
||||
codeText: 10,
|
||||
titleText: 13,
|
||||
bodyText: 11,
|
||||
},
|
||||
// 크게: 여유 - 헤더(32) + 본문(이미지64+필드3x18=54) + 패딩(10*3) = ~156px
|
||||
large: {
|
||||
height: 160,
|
||||
imageSize: 64,
|
||||
padding: 10,
|
||||
gap: 8,
|
||||
headerPadY: 6,
|
||||
headerPadX: 10,
|
||||
codeText: 11,
|
||||
titleText: 15,
|
||||
bodyText: 12,
|
||||
},
|
||||
};
|
||||
|
||||
export interface PopCardListConfig {
|
||||
// 데이터 소스 (테이블 단위)
|
||||
dataSource: CardListDataSource;
|
||||
|
||||
// 카드 템플릿 (헤더 + 이미지 + 본문)
|
||||
cardTemplate: CardTemplateConfig;
|
||||
|
||||
// 스크롤 방향
|
||||
scrollDirection: CardScrollDirection;
|
||||
cardsPerRow?: number; // deprecated, gridColumns 사용
|
||||
cardSize: CardSize; // 프리셋 크기 (small/medium/large)
|
||||
|
||||
// 그리드 배치 설정 (가로 x 세로)
|
||||
gridColumns?: number; // 가로 카드 수 (기본값: 3)
|
||||
gridRows?: number; // 세로 카드 수 (기본값: 2)
|
||||
|
||||
// 확장 설정 (더보기 클릭 시 그리드 rowSpan 변경)
|
||||
// expandedRowSpan 제거됨 - 항상 원래 크기의 2배로 확장
|
||||
|
||||
// 입력 필드 설정 (수량 입력 등)
|
||||
inputField?: CardInputFieldConfig;
|
||||
|
||||
// 계산 필드 설정 (미입고 등 자동 계산)
|
||||
calculatedField?: CardCalculatedFieldConfig;
|
||||
|
||||
// 담기 버튼 액션 설정 (pop-icon 스타일)
|
||||
cartAction?: CardCartActionConfig;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,11 @@ const nextConfig = {
|
|||
},
|
||||
|
||||
// API 프록시 설정 - 백엔드로 요청 전달
|
||||
// Docker 환경: SERVER_API_URL=http://backend:3001 사용
|
||||
// 로컬 개발: http://localhost:8080 사용
|
||||
// Docker 환경: 컨테이너 이름(pms-backend-mac) 또는 SERVER_API_URL 사용
|
||||
// 로컬 개발: http://127.0.0.1:8080 사용
|
||||
async rewrites() {
|
||||
const backendUrl = process.env.SERVER_API_URL || "http://localhost:8080";
|
||||
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
||||
const backendUrl = process.env.SERVER_API_URL || "http://pms-backend-mac:8080";
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
|
|
@ -48,8 +49,8 @@ const nextConfig = {
|
|||
|
||||
// 환경 변수 (런타임에 읽기)
|
||||
env: {
|
||||
// 항상 명시적으로 백엔드 포트(8080)를 지정
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api",
|
||||
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://pms-backend-mac:8080/api",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -303,6 +304,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -336,6 +338,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
|
@ -2666,6 +2669,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
||||
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/react-reconciler": "^0.32.0",
|
||||
|
|
@ -3319,6 +3323,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
||||
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.6"
|
||||
},
|
||||
|
|
@ -3386,6 +3391,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
|
|
@ -3699,6 +3705,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
|
|
@ -6199,6 +6206,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -6209,6 +6217,7 @@
|
|||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -6251,6 +6260,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
|
|
@ -6333,6 +6343,7 @@
|
|||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
|
|
@ -6965,6 +6976,7 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -8115,7 +8127,8 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
|
|
@ -8437,6 +8450,7 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -9196,6 +9210,7 @@
|
|||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -9284,6 +9299,7 @@
|
|||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
|
|
@ -9385,6 +9401,7 @@
|
|||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
|
|
@ -10556,6 +10573,7 @@
|
|||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
|
|
@ -11336,7 +11354,8 @@
|
|||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
|
|
@ -12665,6 +12684,7 @@
|
|||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
|
|
@ -12958,6 +12978,7 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
|
|
@ -12987,6 +13008,7 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
|
|
@ -13035,6 +13057,7 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
|
|
@ -13161,6 +13184,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -13230,6 +13254,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -13280,6 +13305,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
|
@ -13312,7 +13338,8 @@
|
|||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
|
|
@ -13620,6 +13647,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
|
|
@ -13642,7 +13670,8 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/recharts/node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -14672,7 +14701,8 @@
|
|||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
|
|
@ -14760,6 +14790,7 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -15108,6 +15139,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* 카드 목록 컴포넌트 E2E 테스트
|
||||
* 실행: npx tsx scripts/test-card-list-e2e.ts
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
const BASE_URL = "http://localhost:9771";
|
||||
const SCREEN_URL = "/pop/screens/4114";
|
||||
const SCREENSHOT_DIR = path.join(process.cwd(), "test-screenshots");
|
||||
|
||||
async function ensureDir(dir: string) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("카드 목록 컴포넌트 E2E 테스트 시작...");
|
||||
await ensureDir(SCREENSHOT_DIR);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
const results: string[] = [];
|
||||
|
||||
try {
|
||||
// 1. 페이지 로드
|
||||
console.log("1. 페이지 로드 중...");
|
||||
await page.goto(`${BASE_URL}${SCREEN_URL}`, { waitUntil: "networkidle", timeout: 15000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 카드 목록 컴포넌트 확인
|
||||
const cardContainer = await page.locator('[class*="grid"]').first();
|
||||
const cardCount = await page.locator(".rounded-lg.border.bg-card").count();
|
||||
const hasCards = cardCount > 0;
|
||||
results.push(`1. 카드 목록 표시: ${hasCards ? "OK" : "FAIL"} (카드 ${cardCount}개)`);
|
||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "01-loaded.png") });
|
||||
|
||||
// 2. "더보기" 버튼 클릭
|
||||
const moreBtn = page.getByRole("button", { name: /더보기/ });
|
||||
const moreBtnCount = await moreBtn.count();
|
||||
|
||||
if (moreBtnCount > 0) {
|
||||
console.log("2. 더보기 버튼 클릭...");
|
||||
await moreBtn.first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const cardCountAfter = await page.locator(".rounded-lg.border.bg-card").count();
|
||||
const expanded = cardCountAfter > cardCount;
|
||||
results.push(`2. 더보기 클릭 후 확장: ${expanded ? "OK" : "카드 수 변화 없음"} (${cardCount} -> ${cardCountAfter})`);
|
||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "02-expanded.png") });
|
||||
|
||||
// 3. 페이지네이션 확인
|
||||
const prevBtn = page.getByRole("button", { name: /이전/ });
|
||||
const nextBtn = page.getByRole("button", { name: /다음/ });
|
||||
const hasPagination = (await prevBtn.count() > 0) || (await nextBtn.count() > 0);
|
||||
results.push(`3. 페이지네이션 버튼: ${hasPagination ? "OK" : "없음 (데이터 적음 시 정상)"}`);
|
||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "03-pagination.png") });
|
||||
|
||||
// 4. 접기 버튼 클릭
|
||||
const collapseBtn = page.getByRole("button", { name: /접기/ });
|
||||
if (await collapseBtn.count() > 0) {
|
||||
console.log("4. 접기 버튼 클릭...");
|
||||
await collapseBtn.first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
const cardCountCollapsed = await page.locator(".rounded-lg.border.bg-card").count();
|
||||
results.push(`4. 접기 후: OK (카드 ${cardCountCollapsed}개로 복원)`);
|
||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "04-collapsed.png") });
|
||||
} else {
|
||||
results.push("4. 접기 버튼: 없음 (확장 안됐을 수 있음)");
|
||||
}
|
||||
} else {
|
||||
results.push("2. 더보기 버튼: 없음 (카드가 적거나 모두 표시됨)");
|
||||
results.push("3. 페이지네이션: N/A");
|
||||
results.push("4. 접기: N/A");
|
||||
}
|
||||
|
||||
// 결과 출력
|
||||
console.log("\n=== 테스트 결과 ===");
|
||||
results.forEach((r) => console.log(r));
|
||||
console.log(`\n스크린샷 저장: ${SCREENSHOT_DIR}`);
|
||||
} catch (err) {
|
||||
console.error("테스트 실패:", err);
|
||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "error.png") });
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -24,5 +24,5 @@
|
|||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", ".next"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,286 +0,0 @@
|
|||
# POP 화면 시스템 아키텍처
|
||||
|
||||
**최종 업데이트: 2026-02-06 (v5.2 브레이크포인트 재설계 + 세로 자동 확장)**
|
||||
|
||||
POP(Point of Production) 화면은 모바일/태블릿 환경에 최적화된 터치 기반 화면 시스템입니다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 버전: v5 (CSS Grid)
|
||||
|
||||
| 항목 | v5 (현재) |
|
||||
|------|----------|
|
||||
| 레이아웃 | CSS Grid |
|
||||
| 배치 방식 | 좌표 기반 (col, row, colSpan, rowSpan) |
|
||||
| 모드 | 4개 (mobile_portrait, mobile_landscape, tablet_portrait, tablet_landscape) |
|
||||
| 칸 수 | 4/6/8/12칸 |
|
||||
|
||||
---
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── app/(pop)/ # Next.js App Router
|
||||
│ ├── layout.tsx # POP 전용 레이아웃
|
||||
│ └── pop/
|
||||
│ ├── page.tsx # 대시보드
|
||||
│ ├── screens/[screenId]/ # 화면 뷰어 (v5)
|
||||
│ └── work/ # 작업 화면
|
||||
│
|
||||
├── components/pop/ # POP 컴포넌트
|
||||
│ ├── designer/ # 디자이너 모듈 ★
|
||||
│ │ ├── PopDesigner.tsx # 메인 (레이아웃 로드/저장)
|
||||
│ │ ├── PopCanvas.tsx # 캔버스 (DnD, 줌, 모드)
|
||||
│ │ ├── panels/
|
||||
│ │ │ └── ComponentEditorPanel.tsx # 속성 편집
|
||||
│ │ ├── renderers/
|
||||
│ │ │ └── PopRenderer.tsx # CSS Grid 렌더링
|
||||
│ │ ├── types/
|
||||
│ │ │ └── pop-layout.ts # v5 타입 정의
|
||||
│ │ └── utils/
|
||||
│ │ └── gridUtils.ts # 위치 계산
|
||||
│ ├── management/ # 화면 관리
|
||||
│ └── dashboard/ # 대시보드
|
||||
│
|
||||
└── lib/
|
||||
├── api/screen.ts # 화면 API
|
||||
└── registry/ # 컴포넌트 레지스트리
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 파일
|
||||
|
||||
### 1. PopDesigner.tsx (메인)
|
||||
|
||||
**역할**: 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리
|
||||
|
||||
```typescript
|
||||
// 상태 관리
|
||||
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
||||
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
|
||||
|
||||
// 핵심 함수
|
||||
handleSave() // 레이아웃 저장
|
||||
handleAddComponent() // 컴포넌트 추가
|
||||
handleUpdateComponent() // 컴포넌트 수정
|
||||
handleDeleteComponent() // 컴포넌트 삭제
|
||||
handleUndo() / handleRedo() // 히스토리
|
||||
```
|
||||
|
||||
### 2. PopCanvas.tsx (캔버스)
|
||||
|
||||
**역할**: 그리드 캔버스, DnD, 줌, 패닝, 모드 전환
|
||||
|
||||
```typescript
|
||||
// DnD 설정
|
||||
const DND_ITEM_TYPES = { COMPONENT: "component" };
|
||||
|
||||
// 뷰포트 프리셋 (4개 모드) - height 제거됨 (세로 무한 스크롤)
|
||||
const VIEWPORT_PRESETS = [
|
||||
{ id: "mobile_portrait", width: 375, columns: 4 },
|
||||
{ id: "mobile_landscape", width: 600, columns: 6 },
|
||||
{ id: "tablet_portrait", width: 834, columns: 8 },
|
||||
{ id: "tablet_landscape", width: 1024, columns: 12 },
|
||||
];
|
||||
|
||||
// 세로 자동 확장
|
||||
const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이
|
||||
const CANVAS_EXTRA_ROWS = 3; // 항상 유지되는 여유 행 수
|
||||
const dynamicCanvasHeight = useMemo(() => { ... }, []);
|
||||
|
||||
// 기능
|
||||
- useDrop(): 팔레트에서 컴포넌트 드롭
|
||||
- handleWheel(): 줌 (30%~150%)
|
||||
- Space + 드래그: 패닝
|
||||
```
|
||||
|
||||
### 3. PopRenderer.tsx (렌더러)
|
||||
|
||||
**역할**: CSS Grid 기반 레이아웃 렌더링
|
||||
|
||||
```typescript
|
||||
// Props
|
||||
interface PopRendererProps {
|
||||
layout: PopLayoutDataV5;
|
||||
viewportWidth: number;
|
||||
currentMode: GridMode;
|
||||
isDesignMode: boolean;
|
||||
selectedComponentId?: string | null;
|
||||
onSelectComponent?: (id: string | null) => void;
|
||||
}
|
||||
|
||||
// CSS Grid 스타일 생성
|
||||
const gridStyle = useMemo(() => ({
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
||||
gap: `${gap}px`,
|
||||
padding: `${padding}px`,
|
||||
}), [mode]);
|
||||
|
||||
// 위치 변환 (12칸 → 다른 모드)
|
||||
const convertPosition = (pos: PopGridPosition, targetMode: GridMode) => {
|
||||
const ratio = GRID_BREAKPOINTS[targetMode].columns / 12;
|
||||
return {
|
||||
col: Math.max(1, Math.round(pos.col * ratio)),
|
||||
colSpan: Math.max(1, Math.round(pos.colSpan * ratio)),
|
||||
row: pos.row,
|
||||
rowSpan: pos.rowSpan,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 4. ComponentEditorPanel.tsx (속성 패널)
|
||||
|
||||
**역할**: 선택된 컴포넌트 속성 편집
|
||||
|
||||
```typescript
|
||||
// 탭 구조
|
||||
- grid: col, row, colSpan, rowSpan (기본 모드에서만 편집)
|
||||
- settings: label, type 등
|
||||
- data: 데이터 바인딩 (미구현)
|
||||
- visibility: 모드별 표시/숨김
|
||||
```
|
||||
|
||||
### 5. pop-layout.ts (타입 정의)
|
||||
|
||||
**역할**: v5 타입 정의
|
||||
|
||||
```typescript
|
||||
// 그리드 모드
|
||||
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
|
||||
|
||||
// 브레이크포인트 설정 (2026-02-06 재설계)
|
||||
const GRID_BREAKPOINTS = {
|
||||
mobile_portrait: { columns: 4, maxWidth: 479, gap: 8, padding: 12 },
|
||||
mobile_landscape: { columns: 6, minWidth: 480, maxWidth: 767, gap: 8, padding: 16 },
|
||||
tablet_portrait: { columns: 8, minWidth: 768, maxWidth: 1023, gap: 12, padding: 20 },
|
||||
tablet_landscape: { columns: 12, minWidth: 1024, gap: 12, padding: 24 },
|
||||
};
|
||||
|
||||
// 모드 감지 (순수 너비 기반)
|
||||
function detectGridMode(viewportWidth: number): GridMode {
|
||||
if (viewportWidth < 480) return "mobile_portrait";
|
||||
if (viewportWidth < 768) return "mobile_landscape";
|
||||
if (viewportWidth < 1024) return "tablet_portrait";
|
||||
return "tablet_landscape";
|
||||
}
|
||||
|
||||
// 레이아웃 데이터
|
||||
interface PopLayoutDataV5 {
|
||||
version: "pop-5.0";
|
||||
metadata: PopLayoutMetadata;
|
||||
gridConfig: PopGridConfig;
|
||||
components: PopComponentDefinitionV5[];
|
||||
globalSettings: PopGlobalSettingsV5;
|
||||
}
|
||||
|
||||
// 컴포넌트 정의
|
||||
interface PopComponentDefinitionV5 {
|
||||
id: string;
|
||||
type: PopComponentType;
|
||||
label: string;
|
||||
gridPosition: PopGridPosition; // col, row, colSpan, rowSpan
|
||||
config: PopComponentConfig;
|
||||
visibility: Record<GridMode, boolean>;
|
||||
modeOverrides?: Record<GridMode, PopModeOverrideV5>;
|
||||
}
|
||||
|
||||
// 위치
|
||||
interface PopGridPosition {
|
||||
col: number; // 시작 열 (1부터)
|
||||
row: number; // 시작 행 (1부터)
|
||||
colSpan: number; // 열 크기 (1~12)
|
||||
rowSpan: number; // 행 크기 (1~)
|
||||
}
|
||||
```
|
||||
|
||||
### 6. gridUtils.ts (유틸리티)
|
||||
|
||||
**역할**: 그리드 위치 계산
|
||||
|
||||
```typescript
|
||||
// 위치 변환
|
||||
convertPositionToMode(pos, targetMode)
|
||||
|
||||
// 겹침 감지
|
||||
isOverlapping(posA, posB)
|
||||
|
||||
// 빈 위치 찾기
|
||||
findNextEmptyPosition(layout, mode)
|
||||
|
||||
// 마우스 → 그리드 좌표
|
||||
mouseToGridPosition(mouseX, mouseY, canvasRect, mode)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
```
|
||||
[사용자 액션]
|
||||
↓
|
||||
[PopDesigner] ← 상태 관리 (layout, selectedComponentId, history)
|
||||
↓
|
||||
[PopCanvas] ← DnD, 줌, 모드 전환
|
||||
↓
|
||||
[PopRenderer] ← CSS Grid 렌더링
|
||||
↓
|
||||
[컴포넌트 표시]
|
||||
```
|
||||
|
||||
### 저장 흐름
|
||||
|
||||
```
|
||||
[저장 버튼]
|
||||
↓
|
||||
PopDesigner.handleSave()
|
||||
↓
|
||||
screenApi.saveLayoutPop(screenId, layout)
|
||||
↓
|
||||
[백엔드] screenManagementService.saveLayoutPop()
|
||||
↓
|
||||
[DB] screen_layouts_pop 테이블
|
||||
```
|
||||
|
||||
### 로드 흐름
|
||||
|
||||
```
|
||||
[페이지 로드]
|
||||
↓
|
||||
PopDesigner useEffect
|
||||
↓
|
||||
screenApi.getLayoutPop(screenId)
|
||||
↓
|
||||
isV5Layout(data) 체크
|
||||
↓
|
||||
setLayout(data) 또는 createEmptyPopLayoutV5()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/screen-management/layout-pop/:screenId` | 레이아웃 조회 |
|
||||
| POST | `/api/screen-management/layout-pop/:screenId` | 레이아웃 저장 |
|
||||
|
||||
---
|
||||
|
||||
## 삭제된 레거시 (참고용)
|
||||
|
||||
| 파일 | 버전 | 이유 |
|
||||
|------|------|------|
|
||||
| PopCanvasV4.tsx | v4 | Flexbox 기반, v5로 대체 |
|
||||
| PopFlexRenderer.tsx | v4 | Flexbox 렌더러, v5로 대체 |
|
||||
| PopLayoutRenderer.tsx | v3 | 절대 좌표 기반, v5로 대체 |
|
||||
| ComponentEditorPanelV4.tsx | v4 | v5 전용으로 통합 |
|
||||
|
||||
---
|
||||
|
||||
*상세 스펙: [SPEC.md](./SPEC.md) | 파일 목록: [FILES.md](./FILES.md)*
|
||||
1205
popdocs/CHANGELOG.md
1205
popdocs/CHANGELOG.md
File diff suppressed because it is too large
Load Diff
646
popdocs/FILES.md
646
popdocs/FILES.md
|
|
@ -1,646 +0,0 @@
|
|||
# POP 파일 상세 목록
|
||||
|
||||
**최종 업데이트: 2026-02-06 (v5.2 브레이크포인트 재설계 + 세로 자동 확장)**
|
||||
|
||||
이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [App Router 파일](#1-app-router-파일)
|
||||
2. [Designer 파일](#2-designer-파일)
|
||||
3. [Panels 파일](#3-panels-파일)
|
||||
4. [Renderers 파일](#4-renderers-파일)
|
||||
5. [Types 파일](#5-types-파일)
|
||||
6. [Utils 파일](#6-utils-파일)
|
||||
7. [Management 파일](#7-management-파일)
|
||||
8. [Dashboard 파일](#8-dashboard-파일)
|
||||
9. [Library 파일](#9-library-파일)
|
||||
10. [루트 컴포넌트 파일](#10-루트-컴포넌트-파일)
|
||||
|
||||
---
|
||||
|
||||
## 1. App Router 파일
|
||||
|
||||
### `frontend/app/(pop)/layout.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | POP 전용 레이아웃 래퍼 |
|
||||
| 라우트 그룹 | `(pop)` - URL에 포함되지 않음 |
|
||||
| 특징 | 데스크톱과 분리된 터치 최적화 환경 |
|
||||
|
||||
---
|
||||
|
||||
### `frontend/app/(pop)/pop/page.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | POP 메인 대시보드 |
|
||||
| 경로 | `/pop` |
|
||||
| 사용 컴포넌트 | `PopDashboard` |
|
||||
|
||||
---
|
||||
|
||||
### `frontend/app/(pop)/pop/screens/[screenId]/page.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | 개별 POP 화면 뷰어 (v5 전용) |
|
||||
| 경로 | `/pop/screens/:screenId` |
|
||||
| 버전 | v5 그리드 시스템 전용 |
|
||||
|
||||
**핵심 코드 구조**:
|
||||
|
||||
```typescript
|
||||
// v5 레이아웃 상태
|
||||
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
||||
|
||||
// 레이아웃 로드
|
||||
useEffect(() => {
|
||||
const popLayout = await screenApi.getLayoutPop(screenId);
|
||||
|
||||
if (isV5Layout(popLayout)) {
|
||||
setLayout(popLayout);
|
||||
} else {
|
||||
// 레거시 레이아웃은 빈 v5로 처리
|
||||
setLayout(createEmptyPopLayoutV5());
|
||||
}
|
||||
}, [screenId]);
|
||||
|
||||
// v5 그리드 렌더링
|
||||
{hasComponents ? (
|
||||
<PopRenderer
|
||||
layout={layout}
|
||||
viewportWidth={viewportWidth}
|
||||
currentMode={currentModeKey}
|
||||
isDesignMode={false}
|
||||
/>
|
||||
) : (
|
||||
// 빈 화면
|
||||
)}
|
||||
```
|
||||
|
||||
**제공 기능**:
|
||||
- 반응형 모드 감지 (useResponsiveModeWithOverride)
|
||||
- 프리뷰 모드 (`?preview=true`)
|
||||
- 디바이스/방향 수동 전환 (프리뷰 모드)
|
||||
- 4개 그리드 모드 지원
|
||||
|
||||
---
|
||||
|
||||
### `frontend/app/(pop)/pop/work/page.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | 작업 화면 (샘플) |
|
||||
| 경로 | `/pop/work` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Designer 파일
|
||||
|
||||
### `frontend/components/pop/designer/PopDesigner.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | POP 화면 디자이너 메인 (v5 전용) |
|
||||
| 의존성 | react-dnd, ResizablePanelGroup |
|
||||
|
||||
**핵심 Props**:
|
||||
|
||||
```typescript
|
||||
interface PopDesignerProps {
|
||||
selectedScreen: ScreenDefinition;
|
||||
onBackToList: () => void;
|
||||
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**상태 관리**:
|
||||
|
||||
```typescript
|
||||
// v5 레이아웃
|
||||
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
||||
|
||||
// 선택 상태
|
||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||
|
||||
// 그리드 모드 (4개)
|
||||
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
||||
|
||||
// UI 상태
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
```
|
||||
|
||||
**주요 핸들러**:
|
||||
|
||||
| 핸들러 | 역할 |
|
||||
|--------|------|
|
||||
| `handleDropComponent` | 컴포넌트 드롭 (그리드 위치 계산) |
|
||||
| `handleUpdateComponent` | 컴포넌트 속성 수정 |
|
||||
| `handleDeleteComponent` | 컴포넌트 삭제 |
|
||||
| `handleSave` | v5 레이아웃 저장 |
|
||||
|
||||
---
|
||||
|
||||
### `frontend/components/pop/designer/PopCanvas.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | v5 CSS Grid 기반 캔버스 + 행/열 라벨 |
|
||||
| 렌더링 | CSS Grid (4/6/8/12칸) |
|
||||
| 모드 | 4개 (태블릿/모바일 x 가로/세로) |
|
||||
| 라벨 | 열 라벨 (1~12), 행 라벨 (1~20) |
|
||||
| 토글 | 그리드 ON/OFF 버튼 |
|
||||
|
||||
**핵심 Props**:
|
||||
|
||||
```typescript
|
||||
interface PopCanvasProps {
|
||||
layout: PopLayoutDataV5;
|
||||
selectedComponentId: string | null;
|
||||
currentMode: GridMode;
|
||||
onModeChange: (mode: GridMode) => void;
|
||||
onSelectComponent: (id: string | null) => void;
|
||||
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
|
||||
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
onDeleteComponent: (componentId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**뷰포트 프리셋** (v5.2 - height 제거됨, 세로 자동 확장):
|
||||
|
||||
```typescript
|
||||
const VIEWPORT_PRESETS = [
|
||||
{ id: "mobile_portrait", label: "모바일 세로", width: 375, columns: 4 },
|
||||
{ id: "mobile_landscape", label: "모바일 가로", width: 600, columns: 6 },
|
||||
{ id: "tablet_portrait", label: "태블릿 세로", width: 834, columns: 8 },
|
||||
{ id: "tablet_landscape", label: "태블릿 가로", width: 1024, columns: 12 },
|
||||
];
|
||||
|
||||
// 세로 자동 확장
|
||||
const MIN_CANVAS_HEIGHT = 600;
|
||||
const CANVAS_EXTRA_ROWS = 3;
|
||||
const dynamicCanvasHeight = useMemo(() => { ... }, []);
|
||||
```
|
||||
|
||||
**제공 기능**:
|
||||
- 4개 모드 프리셋 전환
|
||||
- 줌 컨트롤 (30% ~ 150%)
|
||||
- 패닝 (Space + 드래그)
|
||||
- 컴포넌트 드래그 앤 드롭
|
||||
- 그리드 좌표 계산
|
||||
|
||||
---
|
||||
|
||||
### `frontend/components/pop/designer/index.ts`
|
||||
|
||||
```typescript
|
||||
export { default as PopDesigner } from "./PopDesigner";
|
||||
export { default as PopCanvas } from "./PopCanvas";
|
||||
export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel";
|
||||
export { default as PopRenderer } from "./renderers/PopRenderer";
|
||||
export * from "./types";
|
||||
export * from "./utils/gridUtils";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Panels 파일
|
||||
|
||||
### `frontend/components/pop/designer/panels/ComponentEditorPanel.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | v5 컴포넌트 편집 패널 |
|
||||
| 위치 | 오른쪽 사이드바 |
|
||||
|
||||
**핵심 Props**:
|
||||
|
||||
```typescript
|
||||
interface ComponentEditorPanelProps {
|
||||
component: PopComponentDefinitionV5 | null;
|
||||
currentMode: GridMode;
|
||||
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
className?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**3개 탭**:
|
||||
|
||||
| 탭 | 아이콘 | 내용 |
|
||||
|----|--------|------|
|
||||
| `grid` | Grid3x3 | 그리드 위치 (col, row, colSpan, rowSpan) |
|
||||
| `settings` | Settings | 라벨, 타입별 설정 |
|
||||
| `data` | Database | 데이터 바인딩 (Phase 4) |
|
||||
|
||||
---
|
||||
|
||||
### `frontend/components/pop/designer/panels/index.ts`
|
||||
|
||||
```typescript
|
||||
export { default as ComponentEditorPanel, default } from "./ComponentEditorPanel";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Renderers 파일
|
||||
|
||||
### `frontend/components/pop/designer/renderers/PopRenderer.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | v5 레이아웃 CSS Grid 렌더러 + 격자 셀 |
|
||||
| 입력 | PopLayoutDataV5, viewportWidth, currentMode, showGridGuide |
|
||||
| 격자 | 동적 행 수 (컴포넌트 배치에 따라 자동 계산, CSS Grid 좌표계) |
|
||||
|
||||
**핵심 Props**:
|
||||
|
||||
```typescript
|
||||
interface PopRendererProps {
|
||||
layout: PopLayoutDataV5;
|
||||
viewportWidth: number;
|
||||
currentMode?: GridMode;
|
||||
isDesignMode?: boolean;
|
||||
selectedComponentId?: string | null;
|
||||
showGridGuide?: boolean; // 격자 표시 여부
|
||||
onComponentClick?: (componentId: string) => void;
|
||||
onBackgroundClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**격자 셀 렌더링**:
|
||||
|
||||
```typescript
|
||||
// 동적 행 수 계산 (컴포넌트 배치 기반)
|
||||
const gridCells = useMemo(() => {
|
||||
const maxRowEnd = Object.values(components).reduce((max, comp) => {
|
||||
const pos = getEffectivePosition(comp);
|
||||
return Math.max(max, pos.row + pos.rowSpan);
|
||||
}, 1);
|
||||
const rowCount = Math.max(10, maxRowEnd + 5);
|
||||
|
||||
const cells = [];
|
||||
for (let row = 1; row <= rowCount; row++) {
|
||||
for (let col = 1; col <= breakpoint.columns; col++) {
|
||||
cells.push({ id: `cell-${col}-${row}`, col, row });
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}, [components, overrides, mode, breakpoint.columns]);
|
||||
|
||||
// 컴포넌트와 동일한 CSS Grid 좌표계로 렌더링
|
||||
{showGridGuide && gridCells.map(cell => (
|
||||
<div
|
||||
key={cell.id}
|
||||
className="border border-dashed border-blue-300/40"
|
||||
style={{ gridColumn: cell.col, gridRow: cell.row }}
|
||||
/>
|
||||
))}
|
||||
```
|
||||
|
||||
**CSS Grid 스타일 생성**:
|
||||
|
||||
```typescript
|
||||
const gridStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
|
||||
gridAutoRows: `${breakpoint.rowHeight}px`,
|
||||
gap: `${breakpoint.gap}px`,
|
||||
padding: `${breakpoint.padding}px`,
|
||||
};
|
||||
```
|
||||
|
||||
**컴포넌트 위치 변환**:
|
||||
|
||||
```typescript
|
||||
const convertPosition = (position: PopGridPosition): React.CSSProperties => ({
|
||||
gridColumn: `${position.col} / span ${position.colSpan}`,
|
||||
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `frontend/components/pop/designer/renderers/index.ts`
|
||||
|
||||
```typescript
|
||||
export { default as PopRenderer, default } from "./PopRenderer";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Types 파일
|
||||
|
||||
### `frontend/components/pop/designer/types/pop-layout.ts`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | POP 레이아웃 v5 타입 시스템 |
|
||||
| 버전 | v5 전용 (레거시 제거됨) |
|
||||
|
||||
**핵심 타입**:
|
||||
|
||||
```typescript
|
||||
// 그리드 모드
|
||||
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
|
||||
|
||||
// 그리드 브레이크포인트
|
||||
interface GridBreakpoint {
|
||||
label: string;
|
||||
columns: number;
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
rowHeight: number;
|
||||
gap: number;
|
||||
padding: number;
|
||||
}
|
||||
|
||||
// v5 레이아웃
|
||||
interface PopLayoutDataV5 {
|
||||
version: "pop-5.0";
|
||||
gridConfig: PopGridConfig;
|
||||
components: Record<string, PopComponentDefinitionV5>;
|
||||
dataFlow: PopDataFlow;
|
||||
settings: PopGlobalSettingsV5;
|
||||
metadata?: PopLayoutMetadata;
|
||||
overrides?: Record<GridMode, PopModeOverrideV5>;
|
||||
}
|
||||
|
||||
// 그리드 위치
|
||||
interface PopGridPosition {
|
||||
col: number; // 시작 열 (1부터)
|
||||
row: number; // 시작 행 (1부터)
|
||||
colSpan: number; // 열 크기
|
||||
rowSpan: number; // 행 크기
|
||||
}
|
||||
|
||||
// v5 컴포넌트 정의
|
||||
interface PopComponentDefinitionV5 {
|
||||
id: string;
|
||||
type: PopComponentType;
|
||||
label?: string;
|
||||
position: PopGridPosition;
|
||||
visibility?: { modes: GridMode[]; defaultVisible: boolean };
|
||||
dataBinding?: PopDataBinding;
|
||||
style?: PopStylePreset;
|
||||
config?: PopComponentConfig;
|
||||
}
|
||||
```
|
||||
|
||||
**주요 함수**:
|
||||
|
||||
| 함수 | 역할 |
|
||||
|------|------|
|
||||
| `createEmptyPopLayoutV5()` | 빈 v5 레이아웃 생성 |
|
||||
| `addComponentToV5Layout()` | v5에 컴포넌트 추가 |
|
||||
| `createComponentDefinitionV5()` | v5 컴포넌트 정의 생성 |
|
||||
| `isV5Layout()` | v5 타입 가드 |
|
||||
| `detectGridMode()` | 뷰포트 너비로 모드 감지 |
|
||||
|
||||
---
|
||||
|
||||
### `frontend/components/pop/designer/types/index.ts`
|
||||
|
||||
```typescript
|
||||
export * from "./pop-layout";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.5. Constants 파일 (신규)
|
||||
|
||||
### `frontend/components/pop/designer/constants/dnd.ts`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | DnD(Drag and Drop) 관련 상수 |
|
||||
| 생성일 | 2026-02-05 |
|
||||
|
||||
**핵심 상수**:
|
||||
|
||||
```typescript
|
||||
export const DND_ITEM_TYPES = {
|
||||
/** 팔레트에서 새 컴포넌트 드래그 */
|
||||
COMPONENT: "POP_COMPONENT",
|
||||
/** 캔버스 내 기존 컴포넌트 이동 */
|
||||
MOVE_COMPONENT: "POP_MOVE_COMPONENT",
|
||||
} as const;
|
||||
```
|
||||
|
||||
**사용처**:
|
||||
- `PopCanvas.tsx` - useDrop accept 타입
|
||||
- `PopRenderer.tsx` - useDrag type
|
||||
- `ComponentPalette.tsx` - useDrag type
|
||||
|
||||
---
|
||||
|
||||
### `frontend/components/pop/designer/constants/index.ts`
|
||||
|
||||
```typescript
|
||||
export * from "./dnd";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Utils 파일
|
||||
|
||||
### `frontend/components/pop/designer/utils/gridUtils.ts`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | 그리드 위치 계산 유틸리티 |
|
||||
| 용도 | 좌표 변환, 겹침 감지, 자동 배치 |
|
||||
|
||||
**주요 함수**:
|
||||
|
||||
| 함수 | 역할 |
|
||||
|------|------|
|
||||
| `convertPositionToMode()` | 12칸 기준 위치를 다른 모드로 변환 |
|
||||
| `isOverlapping()` | 두 위치 겹침 여부 확인 |
|
||||
| `resolveOverlaps()` | 겹침 해결 (아래로 밀기) |
|
||||
| `mouseToGridPosition()` | 마우스 좌표 → 그리드 좌표 |
|
||||
| `gridToPixelPosition()` | 그리드 좌표 → 픽셀 좌표 |
|
||||
| `isValidPosition()` | 위치 유효성 검사 |
|
||||
| `clampPosition()` | 위치 범위 조정 |
|
||||
| `findNextEmptyPosition()` | 다음 빈 위치 찾기 |
|
||||
| `autoLayoutComponents()` | 자동 배치 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Management 파일
|
||||
|
||||
### `frontend/components/pop/management/PopCategoryTree.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | POP 화면 카테고리 트리 |
|
||||
| 기능 | 그룹 추가/수정/삭제, 화면 목록 |
|
||||
|
||||
---
|
||||
|
||||
### `frontend/components/pop/management/PopScreenSettingModal.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | POP 화면 설정 모달 |
|
||||
| 기능 | 화면명, 설명, 그룹 설정 |
|
||||
|
||||
---
|
||||
|
||||
### `frontend/components/pop/management/PopScreenPreview.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | POP 화면 미리보기 |
|
||||
| 기능 | 썸네일, 기본 정보 표시 |
|
||||
|
||||
---
|
||||
|
||||
### `frontend/components/pop/management/PopScreenFlowView.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | 화면 간 플로우 시각화 |
|
||||
| 기능 | 화면 연결 관계 표시 |
|
||||
|
||||
---
|
||||
|
||||
### `frontend/components/pop/management/index.ts`
|
||||
|
||||
```typescript
|
||||
export { PopCategoryTree } from "./PopCategoryTree";
|
||||
export { PopScreenSettingModal } from "./PopScreenSettingModal";
|
||||
export { PopScreenPreview } from "./PopScreenPreview";
|
||||
export { PopScreenFlowView } from "./PopScreenFlowView";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Dashboard 파일
|
||||
|
||||
### `frontend/components/pop/dashboard/PopDashboard.tsx`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | POP 대시보드 메인 |
|
||||
| 구성 | 헤더, KPI, 메뉴그리드, 공지, 푸터 |
|
||||
|
||||
---
|
||||
|
||||
### 기타 Dashboard 컴포넌트
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `DashboardHeader.tsx` | 상단 헤더 |
|
||||
| `DashboardFooter.tsx` | 하단 푸터 |
|
||||
| `MenuGrid.tsx` | 메뉴 그리드 |
|
||||
| `KpiBar.tsx` | KPI 요약 바 |
|
||||
| `NoticeBanner.tsx` | 공지 배너 |
|
||||
| `NoticeList.tsx` | 공지 목록 |
|
||||
| `ActivityList.tsx` | 최근 활동 목록 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Library 파일
|
||||
|
||||
### `frontend/lib/api/popScreenGroup.ts`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | POP 화면 그룹 API 클라이언트 |
|
||||
|
||||
**API 함수**:
|
||||
|
||||
```typescript
|
||||
async function getPopScreenGroups(searchTerm?: string): Promise<PopScreenGroup[]>
|
||||
async function createPopScreenGroup(data: CreatePopScreenGroupRequest): Promise<...>
|
||||
async function updatePopScreenGroup(id: number, data: UpdatePopScreenGroupRequest): Promise<...>
|
||||
async function deletePopScreenGroup(id: number): Promise<...>
|
||||
async function ensurePopRootGroup(): Promise<...>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `frontend/lib/registry/PopComponentRegistry.ts`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | POP 컴포넌트 중앙 레지스트리 |
|
||||
|
||||
---
|
||||
|
||||
### `frontend/lib/schemas/popComponentConfig.ts`
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| 역할 | POP 컴포넌트 설정 스키마 |
|
||||
| 검증 | Zod 기반 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 루트 컴포넌트 파일
|
||||
|
||||
### `frontend/components/pop/index.ts`
|
||||
|
||||
```typescript
|
||||
export * from "./designer";
|
||||
export * from "./management";
|
||||
export * from "./dashboard";
|
||||
// 개별 컴포넌트 export
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 기타 루트 레벨 컴포넌트
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `PopApp.tsx` | POP 앱 셸 |
|
||||
| `PopHeader.tsx` | 공통 헤더 |
|
||||
| `PopBottomNav.tsx` | 하단 네비게이션 |
|
||||
| `PopStatusTabs.tsx` | 상태 탭 |
|
||||
| `PopWorkCard.tsx` | 작업 카드 |
|
||||
| `PopProductionPanel.tsx` | 생산 패널 |
|
||||
| `PopSettingsModal.tsx` | 설정 모달 |
|
||||
| `PopAcceptModal.tsx` | 수락 모달 |
|
||||
| `PopProcessModal.tsx` | 프로세스 모달 |
|
||||
| `PopEquipmentModal.tsx` | 설비 모달 |
|
||||
|
||||
---
|
||||
|
||||
## 파일 수 통계
|
||||
|
||||
| 폴더 | 파일 수 | 설명 |
|
||||
|------|---------|------|
|
||||
| `app/(pop)` | 4 | App Router 페이지 |
|
||||
| `components/pop/designer` | 11 | 디자이너 모듈 (v5) - constants 포함 |
|
||||
| `components/pop/management` | 5 | 관리 모듈 |
|
||||
| `components/pop/dashboard` | 12 | 대시보드 모듈 |
|
||||
| `components/pop` (루트) | 15 | 루트 컴포넌트 |
|
||||
| `lib` | 3 | 라이브러리 |
|
||||
| **총계** | **50** | |
|
||||
|
||||
---
|
||||
|
||||
## 삭제된 파일 (v5 통합으로 제거)
|
||||
|
||||
| 파일 | 이유 |
|
||||
|------|------|
|
||||
| `PopCanvasV4.tsx` | v4 Flexbox 캔버스 |
|
||||
| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 |
|
||||
| `PopLayoutRenderer.tsx` | v3 CSS Grid 렌더러 |
|
||||
| `ComponentRenderer.tsx` | 레거시 컴포넌트 렌더러 |
|
||||
| `ComponentEditorPanelV4.tsx` | v4 편집 패널 |
|
||||
| `PopPanel.tsx` | 레거시 팔레트 패널 |
|
||||
| `test-v4/page.tsx` | v4 테스트 페이지 |
|
||||
| `GridGuide.tsx` | SVG 기반 격자 가이드 (좌표 불일치로 삭제, CSS Grid 통합) |
|
||||
|
||||
---
|
||||
|
||||
*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다. (v5 그리드 시스템 기준)*
|
||||
120
popdocs/INDEX.md
120
popdocs/INDEX.md
|
|
@ -1,120 +0,0 @@
|
|||
# 기능별 색인
|
||||
|
||||
> **용도**: "이 기능 어디있어?", "비슷한 기능 찾아줘"
|
||||
> **검색 팁**: Ctrl+F로 기능명, 키워드 검색
|
||||
|
||||
---
|
||||
|
||||
## 렌더링
|
||||
|
||||
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||
|------|------|--------------|------|
|
||||
| 그리드 렌더링 | PopRenderer.tsx | `PopRenderer` | CSS Grid 기반 v5 렌더링 |
|
||||
| 격자 셀 렌더링 | PopRenderer.tsx | `gridCells` (useMemo) | 12x20 = 240개 DOM 셀 |
|
||||
| 위치 변환 | gridUtils.ts | `convertPositionToMode()` | 12칸 → 4/6/8칸 변환 |
|
||||
| 모드 감지 | pop-layout.ts | `detectGridMode()` | 뷰포트 너비로 모드 판별 |
|
||||
| 컴포넌트 스타일 | PopRenderer.tsx | `convertPosition()` | 그리드 좌표 → CSS |
|
||||
|
||||
## 그리드 가이드
|
||||
|
||||
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||
|------|------|--------------|------|
|
||||
| 격자 셀 | PopRenderer.tsx | `gridCells` | CSS Grid 기반 격자선 (동적 행 수) |
|
||||
| 열 라벨 | PopCanvas.tsx | `gridLabels.columns` | 1~12 표시 |
|
||||
| 행 라벨 | PopCanvas.tsx | `gridLabels.rows` | 동적 계산 (dynamicCanvasHeight 기반) |
|
||||
| 토글 | PopCanvas.tsx | `showGridGuide` 상태 | 격자 ON/OFF |
|
||||
|
||||
## 세로 자동 확장
|
||||
|
||||
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||
|------|------|--------------|------|
|
||||
| 동적 높이 | PopCanvas.tsx | `dynamicCanvasHeight` | 컴포넌트 배치 기반 자동 계산 |
|
||||
| 최소 높이 | PopCanvas.tsx | `MIN_CANVAS_HEIGHT` | 600px 보장 |
|
||||
| 여유 행 | PopCanvas.tsx | `CANVAS_EXTRA_ROWS` | 항상 3행 추가 |
|
||||
| 격자 행 수 | PopRenderer.tsx | `gridCells` | maxRowEnd + 5 동적 계산 |
|
||||
|
||||
## 드래그 앤 드롭
|
||||
|
||||
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||
|------|------|--------------|------|
|
||||
| 드롭 영역 | PopCanvas.tsx | `useDrop` | 캔버스에 컴포넌트 드롭 |
|
||||
| 좌표 계산 | gridUtils.ts | `mouseToGridPosition()` | 마우스 → 그리드 좌표 |
|
||||
| 빈 위치 찾기 | gridUtils.ts | `findNextEmptyPosition()` | 자동 배치 |
|
||||
| DnD 타입 정의 | PopCanvas.tsx | `DND_ITEM_TYPES` | 인라인 정의 |
|
||||
|
||||
## 편집
|
||||
|
||||
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||
|------|------|--------------|------|
|
||||
| 위치 편집 | ComponentEditorPanel.tsx | position 탭 | col, row 수정 |
|
||||
| 크기 편집 | ComponentEditorPanel.tsx | position 탭 | colSpan, rowSpan 수정 |
|
||||
| 라벨 편집 | ComponentEditorPanel.tsx | settings 탭 | 컴포넌트 라벨 |
|
||||
| 표시/숨김 | ComponentEditorPanel.tsx | visibility 탭 | 모드별 표시 |
|
||||
|
||||
## 레이아웃 관리
|
||||
|
||||
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||
|------|------|--------------|------|
|
||||
| 컴포넌트 추가 | pop-layout.ts | `addComponentToV5Layout()` | v5에 컴포넌트 추가 |
|
||||
| 빈 레이아웃 | pop-layout.ts | `createEmptyPopLayoutV5()` | 초기 레이아웃 생성 |
|
||||
| 타입 가드 | pop-layout.ts | `isV5Layout()` | v5 여부 확인 |
|
||||
|
||||
## 상태 관리
|
||||
|
||||
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||
|------|------|--------------|------|
|
||||
| 레이아웃 상태 | PopDesigner.tsx | `useState<PopLayoutDataV5>` | 메인 레이아웃 |
|
||||
| 히스토리 | PopDesigner.tsx | `history[]`, `historyIndex` | Undo/Redo |
|
||||
| 선택 상태 | PopDesigner.tsx | `selectedComponentId` | 현재 선택 |
|
||||
| 모드 상태 | PopDesigner.tsx | `currentMode` | 그리드 모드 |
|
||||
|
||||
## 저장/로드
|
||||
|
||||
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||
|------|------|--------------|------|
|
||||
| 레이아웃 로드 | PopDesigner.tsx | `useEffect` | 화면 로드 시 |
|
||||
| 레이아웃 저장 | PopDesigner.tsx | `handleSave()` | 저장 버튼 |
|
||||
| API 호출 | screen.ts (lib/api) | `screenApi.saveLayoutPop()` | 백엔드 통신 |
|
||||
|
||||
## 뷰포트/줌
|
||||
|
||||
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||
|------|------|--------------|------|
|
||||
| 프리셋 전환 | PopCanvas.tsx | `VIEWPORT_PRESETS` | 4개 모드 (width만, height 제거) |
|
||||
| 줌 컨트롤 | PopCanvas.tsx | `canvasScale` | 30%~150% |
|
||||
| 패닝 | PopCanvas.tsx | Space + 드래그 | 캔버스 이동 |
|
||||
| 모드 감지 | pop-layout.ts | `detectGridMode()` | 너비 기반 모드 판별 |
|
||||
|
||||
## 브레이크포인트
|
||||
|
||||
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||
|------|------|--------------|------|
|
||||
| 그리드 설정 | pop-layout.ts | `GRID_BREAKPOINTS` | 모드별 칸 수, gap, padding |
|
||||
| 모드 감지 | pop-layout.ts | `detectGridMode()` | viewportWidth → GridMode |
|
||||
| 훅 연동 | useDeviceOrientation.ts | `BREAKPOINTS.TABLET_MIN` | 768px (태블릿 경계) |
|
||||
|
||||
## 자동 줄바꿈/검토
|
||||
|
||||
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||
|------|------|--------------|------|
|
||||
| 자동 배치 | gridUtils.ts | `convertAndResolvePositions()` | col > maxCol → 맨 아래 배치 |
|
||||
| 검토 필요 판별 | gridUtils.ts | `needsReview()` | 오버라이드 없으면 true |
|
||||
| 검토 패널 | PopCanvas.tsx | `ReviewPanel` | 검토 필요 컴포넌트 목록 |
|
||||
|
||||
---
|
||||
|
||||
## 파일별 주요 기능
|
||||
|
||||
| 파일 | 핵심 기능 |
|
||||
|------|----------|
|
||||
| PopDesigner.tsx | 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리 |
|
||||
| PopCanvas.tsx | DnD, 줌, 패닝, 모드 전환, 행/열 라벨, 격자 토글 |
|
||||
| PopRenderer.tsx | CSS Grid 렌더링, 격자 셀, 위치 변환, 컴포넌트 표시 |
|
||||
| ComponentEditorPanel.tsx | 속성 편집 (위치, 크기, 설정, 표시) |
|
||||
| ComponentPalette.tsx | 컴포넌트 팔레트 (드래그 가능한 컴포넌트 목록) |
|
||||
| pop-layout.ts | 타입 정의, 유틸리티 함수, 상수 |
|
||||
| gridUtils.ts | 좌표 계산, 겹침 감지, 자동 배치 |
|
||||
|
||||
---
|
||||
|
||||
*새 기능 추가 시 해당 카테고리 테이블에 행 추가*
|
||||
150
popdocs/PLAN.md
150
popdocs/PLAN.md
|
|
@ -1,150 +0,0 @@
|
|||
# POP 개발 계획
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태 (2026-02-06)
|
||||
|
||||
**v5.2 그리드 시스템 완성 (브레이크포인트 재설계 + 세로 자동 확장)**
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
```
|
||||
[Phase 1~3] [Phase 5] [Phase 4]
|
||||
v4 Flexbox → v5 CSS Grid → 실제 컴포넌트 구현
|
||||
완료 완료 (v5.2) 다음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 완료된 Phase
|
||||
|
||||
### Phase 1~3: v4 Flexbox 시스템 (완료, 레거시 삭제됨)
|
||||
|
||||
v4 Flexbox 기반 시스템은 v5 CSS Grid로 완전히 대체되었습니다.
|
||||
v4 관련 파일은 모두 삭제되었습니다.
|
||||
|
||||
- [x] v4 기본 구조, 렌더러, 디자이너 통합
|
||||
- [x] Undo/Redo, 드래그 리사이즈, Flexbox 가로 배치
|
||||
- [x] 비율 스케일링 시스템
|
||||
- [x] 오버라이드 기능 (모드별 배치 고정)
|
||||
- [x] 컴포넌트 표시/숨김, 줄바꿈
|
||||
|
||||
### Phase 5: v5 CSS Grid 시스템 (완료)
|
||||
|
||||
#### Phase 5.1: 타입 정의 (완료)
|
||||
- [x] `PopLayoutDataV5` 인터페이스
|
||||
- [x] `PopGridConfig`, `PopGridPosition` 타입
|
||||
- [x] `GridMode`, `GRID_BREAKPOINTS` 상수
|
||||
- [x] `createEmptyPopLayoutV5()`, `isV5Layout()`, `detectGridMode()`
|
||||
|
||||
#### Phase 5.2: 그리드 렌더러 (완료)
|
||||
- [x] `PopRenderer.tsx` - CSS Grid 기반 렌더링
|
||||
- [x] 격자 셀 렌더링 (CSS Grid 동일 좌표계)
|
||||
- [x] 위치 변환 (12칸 -> 4/6/8칸)
|
||||
|
||||
#### Phase 5.3: 디자이너 UI (완료)
|
||||
- [x] `PopCanvas.tsx` - 그리드 캔버스 + 행/열 라벨
|
||||
- [x] 드래그 스냅 (칸에 맞춤)
|
||||
- [x] `ComponentEditorPanel.tsx` - 위치 편집
|
||||
|
||||
#### Phase 5.4: 반응형 자동화 (완료)
|
||||
- [x] 자동 변환 알고리즘 (12칸 -> 4칸)
|
||||
- [x] 겹침 감지 및 재배치
|
||||
- [x] 모드별 오버라이드 저장
|
||||
|
||||
#### v5.1 추가 기능 (완료)
|
||||
- [x] 자동 줄바꿈 (col > maxCol -> 맨 아래 배치)
|
||||
- [x] "검토 필요" 알림 시스템
|
||||
- [x] Gap 프리셋 (좁게/보통/넓게)
|
||||
- [x] 숨김 기능 (모드별)
|
||||
|
||||
#### v5.2 브레이크포인트 재설계 + 세로 자동 확장 (완료)
|
||||
- [x] 기기 기반 브레이크포인트 (479/767/1023px)
|
||||
- [x] 세로 자동 확장 (dynamicCanvasHeight)
|
||||
- [x] 뷰어 반응형 일관성 (detectGridMode 사용)
|
||||
- [x] VIEWPORT_PRESETS에서 height 제거
|
||||
|
||||
---
|
||||
|
||||
## 다음 작업
|
||||
|
||||
### Phase 4: 실제 컴포넌트 구현
|
||||
|
||||
현재 모든 컴포넌트는 `pop-sample` (샘플 박스)로 렌더링됩니다.
|
||||
실제 컴포넌트를 구현하여 데이터 바인딩까지 연결해야 합니다.
|
||||
|
||||
**컴포넌트 구현 목록**:
|
||||
|
||||
- [ ] pop-field: 입력/표시 필드
|
||||
- [ ] pop-button: 액션 버튼
|
||||
- [ ] pop-list: 데이터 리스트 (카드 템플릿)
|
||||
- [ ] pop-indicator: KPI/상태 표시
|
||||
- [ ] pop-scanner: 바코드/QR 스캔
|
||||
- [ ] pop-numpad: 숫자 입력 패드
|
||||
|
||||
**참고 문서**: [components-spec.md](./components-spec.md)
|
||||
|
||||
### 후속 작업
|
||||
|
||||
- [ ] 워크플로우 연동 (버튼 액션, 화면 전환)
|
||||
- [ ] 데이터 바인딩 연결
|
||||
- [ ] 실기기 테스트 (아이폰 SE, iPad Mini 등)
|
||||
|
||||
---
|
||||
|
||||
## 현재 구현 계획
|
||||
|
||||
> **용도**: 이 섹션은 "지금 바로 실행할 구체적 계획"입니다.
|
||||
> 새 세션에서 이 섹션만 읽으면 코딩을 시작할 수 있어야 합니다.
|
||||
> 완료되면 다음 기능의 계획으로 **교체**합니다.
|
||||
|
||||
### 대상: (계획 수립 전)
|
||||
|
||||
현재 구현 계획이 없습니다. 계획 수립 세션에서 다음 형식으로 작성해주세요:
|
||||
|
||||
```
|
||||
### 대상: [기능명]
|
||||
|
||||
#### 구현 순서 (의존성 기반)
|
||||
1. [ ] 파일명 - 변경 내용 요약
|
||||
2. [ ] 파일명 - 변경 내용 요약
|
||||
|
||||
#### 파일별 변경 사항
|
||||
| # | 파일 (경로) | 작업 | 핵심 변경 | 주의사항 |
|
||||
|---|------------|------|----------|---------|
|
||||
|
||||
#### 함정 경고
|
||||
- (빠뜨리면 에러나는 것들)
|
||||
|
||||
#### 참조
|
||||
- 관련 문서/파일 경로
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 브레이크포인트 (v5.2 현재)
|
||||
|
||||
| 모드 | 화면 너비 | 칸 수 | 대상 기기 |
|
||||
|------|----------|-------|----------|
|
||||
| mobile_portrait | ~479px | 4칸 | 아이폰 SE ~ 갤럭시 S |
|
||||
| mobile_landscape | 480~767px | 6칸 | 스마트폰 가로 |
|
||||
| tablet_portrait | 768~1023px | 8칸 | 8~10인치 태블릿 세로 |
|
||||
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 가로 |
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
| 문서 | 내용 |
|
||||
|------|------|
|
||||
| [STATUS.md](./STATUS.md) | 현재 진행 상태 |
|
||||
| [SPEC.md](./SPEC.md) | 기술 스펙 |
|
||||
| [ARCHITECTURE.md](./ARCHITECTURE.md) | 코드 구조 |
|
||||
| [components-spec.md](./components-spec.md) | 컴포넌트 상세 설계 |
|
||||
| [decisions/005](./decisions/005-breakpoint-redesign.md) | 브레이크포인트 재설계 ADR |
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-02-06 (v5.2 완료, Phase 4 대기)*
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
# 문제-해결 색인
|
||||
|
||||
> **용도**: "이전에 비슷한 문제 어떻게 해결했어?"
|
||||
> **검색 팁**: Ctrl+F로 키워드 검색 (에러 메시지, 컴포넌트명 등)
|
||||
|
||||
---
|
||||
|
||||
## 렌더링 관련
|
||||
|
||||
| 문제 | 해결 | 날짜 | 키워드 |
|
||||
|------|------|------|--------|
|
||||
| rowSpan이 적용 안됨 | gridTemplateRows를 `1fr`로 변경 | 2026-02-02 | grid, rowSpan, CSS |
|
||||
| 컴포넌트 크기 스케일 안됨 | viewportWidth 기반 scale 계산 추가 | 2026-02-04 | scale, viewport, 반응형 |
|
||||
| **그리드 가이드 셀 크기 불균일** | gridAutoRows → gridTemplateRows로 행 높이 강제 고정 | 2026-02-06 | gridAutoRows, gridTemplateRows, 셀 크기, CSS Grid |
|
||||
| **컴포넌트 콘텐츠가 셀 경계 벗어남** | overflow-visible → overflow-hidden 변경 | 2026-02-06 | overflow, 셀 크기, 콘텐츠 |
|
||||
|
||||
## DnD (드래그앤드롭) 관련
|
||||
|
||||
| 문제 | 해결 | 날짜 | 키워드 |
|
||||
|------|------|------|--------|
|
||||
| useDrag 에러 (뷰어에서) | isDesignMode 체크 후 early return | 2026-02-04 | DnD, useDrag, 뷰어 |
|
||||
| DndProvider 중복 에러 | 최상위에서만 Provider 사용 | 2026-02-04 | DndProvider, react-dnd |
|
||||
| **Expected drag drop context (뷰어)** | isDesignMode=false일 때 DraggableComponent 대신 일반 div 렌더링 | 2026-02-05 | DndProvider, useDrag, 뷰어, context |
|
||||
| **컴포넌트 중첩(겹침)** | toast import 누락 → `sonner`에서 import | 2026-02-05 | 겹침, overlap, toast |
|
||||
| **리사이즈 핸들 작동 안됨** | useDrop 2개 중복 → 단일 useDrop으로 통합 | 2026-02-05 | resize, 핸들, useDrop |
|
||||
| **드래그 좌표 완전 틀림 (Row 92)** | 캔버스 scale 보정 누락 → `(offset - rect.left) / scale` | 2026-02-05 | scale, 좌표, transform |
|
||||
| **DND 타입 상수 불일치** | 3개 파일에 중복 정의 → `constants/dnd.ts`로 통합 | 2026-02-05 | 상수, DND, 타입 |
|
||||
| **컴포넌트 이동 안됨** | useDrop accept 타입 불일치 → 공통 상수 사용 | 2026-02-05 | 이동, useDrop, accept |
|
||||
|
||||
## 타입 관련
|
||||
|
||||
| 문제 | 해결 | 날짜 | 키워드 |
|
||||
|------|------|------|--------|
|
||||
| 인터페이스 이름 불일치 | V5 접미사 제거, 통일 | 2026-02-05 | 타입, interface, Props |
|
||||
| v3/v4 타입 혼재 | v5 전용으로 통합, 레거시 삭제 | 2026-02-05 | 버전, 타입, 마이그레이션 |
|
||||
|
||||
## 레이아웃 관련
|
||||
|
||||
| 문제 | 해결 | 날짜 | 키워드 |
|
||||
|------|------|------|--------|
|
||||
| **화면 밖 컴포넌트 정보 손실** | 자동 줄바꿈 로직 추가 (col > maxCol → col=1, row=맨아래+1) | 2026-02-06 | 자동배치, 줄바꿈, 정보손실 |
|
||||
| Flexbox 배치 예측 불가 | CSS Grid로 전환 (v5) | 2026-02-05 | Flexbox, Grid, 반응형 |
|
||||
| 4모드 각각 배치 힘듦 | 제약조건 기반 시스템 (v4) | 2026-02-03 | 모드, 반응형, 제약조건 |
|
||||
| 4모드 자동 전환 안됨 | useResponsiveMode 훅 추가 | 2026-02-01 | 모드, 훅, 반응형 |
|
||||
|
||||
## 브레이크포인트/반응형 관련
|
||||
|
||||
| 문제 | 해결 | 날짜 | 키워드 |
|
||||
|------|------|------|--------|
|
||||
| **뷰어 반응형 모드 불일치** | detectGridMode() 사용으로 일관성 확보 | 2026-02-06 | 반응형, 뷰어, 모드 |
|
||||
| **768~839px 모드 불일치** | TABLET_MIN 768로 변경, 브레이크포인트 재설계 | 2026-02-06 | 브레이크포인트, 768px |
|
||||
| **useResponsiveMode vs GRID_BREAKPOINTS 불일치** | 뷰어에서 detectGridMode(viewportWidth) 사용 | 2026-02-06 | 훅, 상수, 일관성 |
|
||||
|
||||
## 저장/로드 관련
|
||||
|
||||
| 문제 | 해결 | 날짜 | 키워드 |
|
||||
|------|------|------|--------|
|
||||
| 레이아웃 버전 충돌 | isV5Layout 타입 가드로 분기 | 2026-02-05 | 버전, 로드, 타입가드 |
|
||||
| 빈 레이아웃 판별 실패 | components 존재 여부로 판별 | 2026-02-04 | 빈 레이아웃, 로드 |
|
||||
|
||||
## UI/UX 관련
|
||||
|
||||
| 문제 | 해결 | 날짜 | 키워드 |
|
||||
|------|------|------|--------|
|
||||
| root 레이아웃 오염 | tempLayout 도입 (임시 상태 분리) | 2026-02-04 | tempLayout, 상태, 오염 |
|
||||
| 속성 패널 다른 모드 수정 | isDefaultMode 체크로 비활성화 | 2026-02-04 | 속성패널, 모드, 비활성화 |
|
||||
|
||||
---
|
||||
|
||||
## 그리드 가이드 관련
|
||||
|
||||
| 문제 | 해결 | 날짜 | 키워드 |
|
||||
|------|------|------|--------|
|
||||
| SVG 격자와 CSS Grid 좌표 불일치 | GridGuide.tsx 삭제, PopRenderer에서 CSS Grid 셀로 격자 렌더링 | 2026-02-05 | 격자, SVG, CSS Grid, 좌표 |
|
||||
| 행/열 라벨 위치 오류 | PopCanvas에 absolute positioning 라벨 추가 | 2026-02-05 | 라벨, 행, 열, 정렬 |
|
||||
| 격자선과 컴포넌트 불일치 | 동일한 CSS Grid 좌표계 사용 | 2026-02-05 | 통합, 정렬, 일체감 |
|
||||
|
||||
---
|
||||
|
||||
## 해결 완료 (이번 세션)
|
||||
|
||||
| 문제 | 상태 | 해결 방법 |
|
||||
|------|------|----------|
|
||||
| PopCanvas 타입 오류 | **해결** | 임시 타입 가드 추가 |
|
||||
| 팔레트 UI 없음 | **해결** | ComponentPalette.tsx 신규 추가 |
|
||||
| SVG 격자 좌표 불일치 | **해결** | CSS Grid 기반 통합 |
|
||||
| 드래그 좌표 완전 틀림 | **해결** | scale 보정 + calcGridPosition 함수 |
|
||||
| DND 타입 상수 불일치 | **해결** | constants/dnd.ts 통합 |
|
||||
| 컴포넌트 이동 안됨 | **해결** | useDrop/useDrag 타입 통일 |
|
||||
| 컴포넌트 중첩(겹침) | **해결** | toast import 추가 → 겹침 감지 로직 정상 작동 |
|
||||
| 리사이즈 핸들 작동 안됨 | **해결** | useDrop 통합 (2개 → 1개) |
|
||||
| 숨김 컴포넌트 드래그 안됨 | **해결** | handleMoveComponent에서 숨김 해제 + 위치 저장 단일 상태 업데이트 |
|
||||
| 그리드 범위 초과 에러 | **해결** | adjustedCol 계산으로 드롭 위치 자동 조정 |
|
||||
| Expected drag drop context (뷰어) | **해결** | isDesignMode=false일 때 일반 div 렌더링 |
|
||||
| hiddenComponentIds 중복 정의 | **해결** | 중복 useMemo 제거 (라인 410-412) |
|
||||
| 뷰어 반응형 모드 불일치 | **해결** | detectGridMode() 사용 |
|
||||
| 그리드 가이드 셀 크기 불균일 | **해결** | gridTemplateRows로 행 높이 강제 고정 |
|
||||
| Canvas vs Renderer 행 수 불일치 | **해결** | 숨김 필터 통일, 여유행 +3으로 통일 |
|
||||
| 디버깅 console.log 잔존 | **해결** | reviewComponents 내 console.log 삭제 |
|
||||
|
||||
---
|
||||
|
||||
## 드래그 좌표 버그 상세 (2026-02-05)
|
||||
|
||||
### 증상
|
||||
- 컴포넌트를 아래로 드래그 → 위로 올라감
|
||||
- Row 92 같은 비정상 좌표
|
||||
- 드래그 이동/리사이즈 전혀 작동 안됨
|
||||
|
||||
### 원인
|
||||
```
|
||||
캔버스: transform: scale(0.8)
|
||||
|
||||
getBoundingClientRect() → 스케일 적용된 크기 (1024px → 819px)
|
||||
getClientOffset() → 뷰포트 기준 실제 마우스 좌표
|
||||
|
||||
이 둘을 그대로 계산하면 좌표 완전 틀림
|
||||
```
|
||||
|
||||
### 해결
|
||||
```typescript
|
||||
// 스케일 보정된 상대 좌표 계산
|
||||
const relX = (offset.x - canvasRect.left) / canvasScale;
|
||||
const relY = (offset.y - canvasRect.top) / canvasScale;
|
||||
|
||||
// 실제 캔버스 크기로 그리드 계산
|
||||
calcGridPosition(relX, relY, customWidth, ...);
|
||||
```
|
||||
|
||||
### 교훈
|
||||
> CSS `transform: scale()` 적용된 요소에서 좌표 계산 시,
|
||||
> `getBoundingClientRect()`는 스케일 적용된 값을 반환하지만
|
||||
> 마우스 좌표는 뷰포트 기준이므로 **반드시 스케일 보정 필요**
|
||||
|
||||
---
|
||||
|
||||
## Expected drag drop context 에러 상세 (2026-02-05 심야)
|
||||
|
||||
### 증상
|
||||
```
|
||||
Invariant Violation: Expected drag drop context
|
||||
at useDrag (...)
|
||||
at DraggableComponent (...)
|
||||
```
|
||||
뷰어 페이지(`/pop/viewer/[screenId]`)에서 POP 화면 조회 시 에러 발생
|
||||
|
||||
### 원인
|
||||
```
|
||||
PopRenderer의 DraggableComponent에서 useDrag 훅을 무조건 호출
|
||||
→ 뷰어 페이지에는 DndProvider가 없음
|
||||
→ React 훅은 조건부 호출 불가 (Rules of Hooks)
|
||||
→ DndProvider 없이 useDrag 호출 시 context 에러
|
||||
```
|
||||
|
||||
### 해결
|
||||
```typescript
|
||||
// PopRenderer.tsx - 컴포넌트 렌더링 부분
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<DraggableComponent ... /> // useDrag 사용
|
||||
);
|
||||
}
|
||||
|
||||
// 뷰어 모드: 드래그 없는 일반 렌더링
|
||||
return (
|
||||
<div className="..." style={positionStyle}>
|
||||
<ComponentContent ... />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### 교훈
|
||||
> React DnD의 `useDrag`/`useDrop` 훅은 반드시 `DndProvider` 내부에서만 호출해야 함.
|
||||
> 디자인 모드와 뷰어 모드를 분기할 때, 훅이 포함된 컴포넌트 자체를 조건부 렌더링해야 함.
|
||||
> 훅 내부에서 `canDrag: false`로 설정해도 훅 자체는 호출되므로 context 에러 발생.
|
||||
|
||||
### 관련 파일
|
||||
- `gridUtils.ts`: convertAndResolvePositions(), needsReview()
|
||||
- `PopCanvas.tsx`: ReviewPanel, ReviewItem
|
||||
- `PopRenderer.tsx`: 자동 배치 위치 렌더링
|
||||
|
||||
---
|
||||
|
||||
## 뷰어 반응형 모드 불일치 상세 (2026-02-06)
|
||||
|
||||
### 증상
|
||||
```
|
||||
- 아이폰 SE, iPad Pro 프리셋은 정상 작동
|
||||
- 브라우저 수동 리사이즈 시 6칸 모드(mobile_landscape)가 적용 안 됨
|
||||
- 768~839px 구간에서 8칸으로 표시됨 (예상: 6칸)
|
||||
```
|
||||
|
||||
### 원인
|
||||
```
|
||||
useResponsiveMode 훅:
|
||||
- deviceType: width/height 비율로 "mobile"/"tablet" 판정
|
||||
- isLandscape: width > height로 판정
|
||||
- BREAKPOINTS.TABLET_MIN = 840 (당시)
|
||||
|
||||
GRID_BREAKPOINTS:
|
||||
- mobile_landscape: 600~839px (6칸)
|
||||
- tablet_portrait: 840~1023px (8칸)
|
||||
|
||||
결과:
|
||||
- 768px 화면 → useResponsiveMode: "tablet" (768 < 840이지만 비율 판정)
|
||||
- 768px 화면 → GRID_BREAKPOINTS: "mobile_landscape" (6칸)
|
||||
- → 모드 불일치!
|
||||
```
|
||||
|
||||
### 해결
|
||||
|
||||
**1단계: 브레이크포인트 재설계**
|
||||
```typescript
|
||||
// 기존
|
||||
mobile_landscape: { minWidth: 600, maxWidth: 839 }
|
||||
tablet_portrait: { minWidth: 840, maxWidth: 1023 }
|
||||
|
||||
// 변경 후
|
||||
mobile_landscape: { minWidth: 480, maxWidth: 767 }
|
||||
tablet_portrait: { minWidth: 768, maxWidth: 1023 }
|
||||
```
|
||||
|
||||
**2단계: 훅 연동**
|
||||
```typescript
|
||||
// useDeviceOrientation.ts
|
||||
BREAKPOINTS.TABLET_MIN: 768 // was 840
|
||||
```
|
||||
|
||||
**3단계: 뷰어 모드 감지 방식 변경**
|
||||
```typescript
|
||||
// page.tsx (뷰어)
|
||||
const currentModeKey = isPreviewMode
|
||||
? getModeKey(deviceType, isLandscape) // 프리뷰: 수동 선택
|
||||
: detectGridMode(viewportWidth); // 일반: 너비 기반 (일관성 확보)
|
||||
```
|
||||
|
||||
### 교훈
|
||||
> 반응형 모드 판정은 **단일 소스(GRID_BREAKPOINTS)**를 기준으로 해야 함.
|
||||
> 훅과 상수가 각각 다른 기준을 사용하면 구간별 불일치 발생.
|
||||
> 뷰어에서는 `detectGridMode(viewportWidth)` 직접 사용으로 일관성 확보.
|
||||
|
||||
---
|
||||
|
||||
## 병합 관련
|
||||
|
||||
| 문제 | 해결 | 날짜 | 키워드 |
|
||||
|------|------|------|--------|
|
||||
| **ScreenDesigner.tsx 3건 충돌** (origin/main 병합) | 함수 시그니처: ksh-v2-work 유지(isPop/defaultDevicePreview), 저장 로직: 3단계 분기 유지+console.log 제거, 툴바 props: origin/main 채택 | 2026-02-09 | 병합, merge, ScreenDesigner, 충돌 |
|
||||
| **usePanelState 중복 선언** (병합 시 발견) | 충돌 1 해결 과정에서 L175의 중복 usePanelState 제거, L215의 완전한 버전만 유지 | 2026-02-09 | usePanelState, 중복, 병합 |
|
||||
| **툴바 JSX 들여쓰기 불일치** (병합 후 린트) | origin/main 코드가 ksh-v2-work와 2칸 들여쓰기 차이. 기능 영향 없음. 추후 포매팅 정리 권장 | 2026-02-09 | 들여쓰기, 포매팅, 린트, prettier |
|
||||
|
||||
---
|
||||
|
||||
*새 문제-해결 추가 시 해당 카테고리 테이블에 행 추가*
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
# POP 화면 시스템
|
||||
|
||||
> **AI 에이전트 시작점**: 이 파일 → STATUS.md 순서로 읽으세요.
|
||||
> 저장 요청 시: [SAVE_RULES.md](./SAVE_RULES.md) 참조
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 버전 | **v5.2** (브레이크포인트 재설계 + 세로 자동 확장) |
|
||||
| 상태 | **반응형 시스템 완성** |
|
||||
| 다음 | Phase 4 (실제 컴포넌트 구현) |
|
||||
|
||||
**마지막 업데이트**: 2026-02-06
|
||||
|
||||
---
|
||||
|
||||
## 마지막 대화 요약
|
||||
|
||||
> **v5.2.1 그리드 셀 크기 강제 고정**:
|
||||
> - gridAutoRows → gridTemplateRows로 행 높이 강제 고정
|
||||
> - "셀의 크기 = 컴포넌트의 크기" 원칙을 코드 수준에서 강제
|
||||
> - Canvas/Renderer 간 행 수 계산 기준 통일 (숨김 필터, 여유행 +3)
|
||||
>
|
||||
> 다음: Phase 4 (실제 컴포넌트 구현)
|
||||
|
||||
---
|
||||
|
||||
## 빠른 경로
|
||||
|
||||
| 알고 싶은 것 | 문서 |
|
||||
|--------------|------|
|
||||
| 지금 뭐 해야 해? | [STATUS.md](./STATUS.md) |
|
||||
| 저장/조회 규칙 | [SAVE_RULES.md](./SAVE_RULES.md) |
|
||||
| 왜 v5로 바꿨어? | [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md) |
|
||||
| 그리드 가이드 설계 | [decisions/004-grid-guide-integration.md](./decisions/004-grid-guide-integration.md) |
|
||||
| 브레이크포인트 재설계 | [decisions/005-breakpoint-redesign.md](./decisions/005-breakpoint-redesign.md) |
|
||||
| 자동 줄바꿈 시스템 | [decisions/006-auto-wrap-review-system.md](./decisions/006-auto-wrap-review-system.md) |
|
||||
| 개발 계획/로드맵 | [PLAN.md](./PLAN.md) |
|
||||
| 지금 바로 코딩할 계획 | [PLAN.md "현재 구현 계획"](./PLAN.md#현재-구현-계획) |
|
||||
| 작업 프롬프트 | [WORKFLOW_PROMPTS.md](./WORKFLOW_PROMPTS.md) |
|
||||
| 컴포넌트 상세 설계 | [components-spec.md](./components-spec.md) |
|
||||
| 이전 문제 해결 | [PROBLEMS.md](./PROBLEMS.md) |
|
||||
| 코드 어디 있어? | [FILES.md](./FILES.md) |
|
||||
| 기능별 색인 | [INDEX.md](./INDEX.md) |
|
||||
| 변경 히스토리 | [CHANGELOG.md](./CHANGELOG.md) |
|
||||
|
||||
---
|
||||
|
||||
## 핵심 파일
|
||||
|
||||
| 파일 | 역할 | 경로 |
|
||||
|------|------|------|
|
||||
| 타입 정의 | v5 레이아웃 타입 | `frontend/components/pop/designer/types/pop-layout.ts` |
|
||||
| 캔버스 | 그리드 캔버스 + DnD + 라벨 | `frontend/components/pop/designer/PopCanvas.tsx` |
|
||||
| 렌더러 | CSS Grid 렌더링 + 격자 셀 | `frontend/components/pop/designer/renderers/PopRenderer.tsx` |
|
||||
| 디자이너 | 메인 컴포넌트 | `frontend/components/pop/designer/PopDesigner.tsx` |
|
||||
| 팔레트 | 컴포넌트 목록 | `frontend/components/pop/designer/panels/ComponentPalette.tsx` |
|
||||
|
||||
---
|
||||
|
||||
## 문서 구조
|
||||
|
||||
```
|
||||
[Layer 1: 먼저 읽기]
|
||||
README.md (지금 여기) → STATUS.md
|
||||
|
||||
[Layer 2: 필요시 읽기]
|
||||
CHANGELOG, PROBLEMS, INDEX, FILES, ARCHITECTURE, SPEC, PLAN
|
||||
|
||||
[Layer 3: 심화]
|
||||
decisions/, sessions/, archive/
|
||||
```
|
||||
|
||||
**컨텍스트 효율화**: 모든 문서를 읽지 마세요. 필요한 것만 단계적으로.
|
||||
|
||||
---
|
||||
|
||||
## POP이란?
|
||||
|
||||
**Point of Production** - 현장 작업자용 모바일/태블릿 화면 시스템
|
||||
|
||||
| 용도 | 경로 |
|
||||
|------|------|
|
||||
| 뷰어 | `/pop/screens/{screenId}` |
|
||||
| 관리 | `/admin/screenMng/popScreenMngList` |
|
||||
| API | `/api/screen-management/layout-pop/:screenId` |
|
||||
|
||||
---
|
||||
|
||||
## v5 그리드 시스템 (현재)
|
||||
|
||||
| 모드 | 화면 너비 | 칸 수 | 대상 기기 |
|
||||
|------|----------|-------|----------|
|
||||
| mobile_portrait | ~479px | 4칸 | 아이폰 SE ~ 갤럭시 S |
|
||||
| mobile_landscape | 480~767px | 6칸 | 스마트폰 가로 |
|
||||
| tablet_portrait | 768~1023px | 8칸 | 8~10인치 태블릿 세로 |
|
||||
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 가로 |
|
||||
|
||||
**핵심**: 컴포넌트를 칸 단위로 배치 (col, row, colSpan, rowSpan)
|
||||
|
||||
**세로 무한 스크롤**: 캔버스 높이 자동 확장 (컴포넌트 배치에 따라)
|
||||
|
||||
**그리드 가이드**: CSS Grid 기반 격자 셀 + 행/열 라벨 (ON/OFF 토글)
|
||||
|
||||
---
|
||||
|
||||
*상세: [SPEC.md](./SPEC.md) | 히스토리: [CHANGELOG.md](./CHANGELOG.md)*
|
||||
|
|
@ -1,574 +0,0 @@
|
|||
# popdocs 사용 규칙
|
||||
|
||||
> **AI 에이전트 필독**: 이 문서는 popdocs 폴더 사용법입니다.
|
||||
> 사용자가 "@popdocs"와 함께 요청하면 이 규칙을 참조하세요.
|
||||
|
||||
---
|
||||
|
||||
## 요청 유형 인식
|
||||
|
||||
### 키워드로 요청 유형 판별
|
||||
|
||||
| 유형 | 키워드 예시 | 행동 |
|
||||
|------|------------|------|
|
||||
| **저장** | 저장해줘, 기록해줘, 정리해줘, 추가해줘 | → 저장 규칙 따르기 |
|
||||
| **조회** | 찾아줘, 검색해줘, 뭐 있어?, 어디있어? | → 조회 규칙 따르기 |
|
||||
| **분석** | 분석해줘, 비교해줘, 어떻게 달라? | → 분석 규칙 따르기 |
|
||||
| **수정** | 수정해줘, 업데이트해줘, 고쳐줘 | → 수정 규칙 따르기 |
|
||||
| **요약** | 요약해줘, 정리해서 보여줘, 보고서 | → 요약 규칙 따르기 |
|
||||
| **작업시작** | 시작하자, 이어서 하자, 뭐 해야 해? | → 작업 시작 규칙 |
|
||||
|
||||
### 요청 유형별 행동
|
||||
|
||||
```
|
||||
[저장 요청]
|
||||
"@popdocs 오늘 작업 저장해줘"
|
||||
→ SAVE_RULES.md 저장 섹션 → 적절한 파일에 저장 → 동기화
|
||||
|
||||
[조회 요청]
|
||||
"@popdocs 이전에 DnD 문제 어떻게 해결했어?"
|
||||
→ PROBLEMS.md 검색 → 관련 내용만 반환
|
||||
|
||||
[분석 요청]
|
||||
"@popdocs v4랑 v5 뭐가 달라?"
|
||||
→ decisions/ 또는 CHANGELOG 검색 → 비교표 생성
|
||||
|
||||
[수정 요청]
|
||||
"@popdocs STATUS 업데이트해줘"
|
||||
→ STATUS.md 수정 → README.md 동기화
|
||||
|
||||
[요약 요청]
|
||||
"@popdocs 이번 주 작업 요약해줘"
|
||||
→ sessions/ 해당 기간 검색 → 요약 생성
|
||||
|
||||
[계획 저장]
|
||||
"@popdocs 구현 계획 저장해줘"
|
||||
→ PLAN.md "현재 구현 계획" 섹션 교체 → STATUS.md 동기화
|
||||
|
||||
[작업 시작]
|
||||
"@popdocs 오늘 작업 시작하자"
|
||||
→ README → STATUS → PLAN.md "현재 구현 계획" → 중단점 확인 → 작업 시작
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 컨텍스트 효율화 원칙
|
||||
|
||||
### Progressive Disclosure (점진적 공개)
|
||||
|
||||
**핵심**: 모든 문서를 한 번에 읽지 마세요. 필요한 것만 단계적으로.
|
||||
|
||||
```
|
||||
Layer 1 (진입점) → README.md, STATUS.md (먼저 읽기, ~100줄)
|
||||
Layer 2 (상세) → 필요한 문서만 선택적으로
|
||||
Layer 3 (심화) → 코드 파일, archive/ (필요시만)
|
||||
```
|
||||
|
||||
### Token as Currency (토큰은 자원)
|
||||
|
||||
| 원칙 | 설명 |
|
||||
|------|------|
|
||||
| **관련성 > 최신성** | 모든 히스토리 대신 관련 있는 것만 |
|
||||
| **요약 > 전문** | 긴 내용 대신 요약 먼저 확인 |
|
||||
| **링크 > 복사** | 내용 복사 대신 파일 경로 참조 |
|
||||
| **테이블 > 산문** | 긴 설명 대신 표로 압축 |
|
||||
| **검색 > 전체읽기** | Ctrl+F 키워드 검색 활용 |
|
||||
|
||||
### Context Bloat 방지
|
||||
|
||||
```
|
||||
❌ 잘못된 방법:
|
||||
"모든 문서를 읽고 파악한 후 작업하겠습니다"
|
||||
→ 1,300줄 이상 낭비
|
||||
|
||||
✅ 올바른 방법:
|
||||
"README → STATUS → 필요한 섹션만"
|
||||
→ 평균 50~100줄로 작업 가능
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문서 구조 (3계층)
|
||||
|
||||
```
|
||||
popdocs/
|
||||
│
|
||||
├── [Layer 1: 진입점] ─────────────────────────
|
||||
│ ├── README.md ← 시작점 (현재 상태 요약)
|
||||
│ ├── STATUS.md ← 진행 상태, 다음 작업
|
||||
│ └── SAVE_RULES.md ← 사용 규칙 (지금 읽는 문서)
|
||||
│
|
||||
├── [Layer 2: 상세 문서] ─────────────────────────
|
||||
│ ├── CHANGELOG.md ← 변경 이력 (날짜별)
|
||||
│ ├── PROBLEMS.md ← 문제-해결 색인
|
||||
│ ├── INDEX.md ← 기능별 색인
|
||||
│ ├── ARCHITECTURE.md ← 코드 구조
|
||||
│ ├── FILES.md ← 파일 목록
|
||||
│ ├── SPEC.md ← 기술 스펙
|
||||
│ └── PLAN.md ← 계획
|
||||
│
|
||||
├── [Layer 3: 심화/기록] ─────────────────────────
|
||||
│ ├── decisions/ ← ADR (결정 기록)
|
||||
│ ├── sessions/ ← 날짜별 작업 기록
|
||||
│ └── archive/ ← 보관 (레거시)
|
||||
│
|
||||
└── [외부 참조] ─────────────────────────
|
||||
└── 실제 코드 → frontend/components/pop/designer/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 조회 규칙 (읽기)
|
||||
|
||||
### 작업 시작 시
|
||||
|
||||
```
|
||||
1. README.md 읽기 (~60줄)
|
||||
└→ 현재 상태, 다음 작업 확인
|
||||
|
||||
2. STATUS.md 읽기 (~40줄)
|
||||
└→ 상세 진행 상황, 중단점 확인
|
||||
|
||||
3. 필요한 문서만 선택적으로
|
||||
```
|
||||
|
||||
### 요청별 조회 경로
|
||||
|
||||
| 사용자 요청 | 조회 경로 |
|
||||
|-------------|----------|
|
||||
| "지금 뭐 해야 해?" | README → STATUS |
|
||||
| "구현 계획 보여줘" | PLAN.md "현재 구현 계획" 섹션 |
|
||||
| "어제 뭐 했어?" | sessions/어제날짜.md |
|
||||
| "이전에 비슷한 문제?" | PROBLEMS.md (키워드 검색) |
|
||||
| "이 기능 어디있어?" | INDEX.md 또는 FILES.md |
|
||||
| "왜 이렇게 결정했어?" | decisions/ |
|
||||
| "전체 히스토리" | CHANGELOG.md (기간 한정) |
|
||||
| "코드 구조 알려줘" | ARCHITECTURE.md |
|
||||
| "v4랑 v5 뭐가 달라?" | decisions/003 또는 CHANGELOG |
|
||||
|
||||
### 효율적 검색
|
||||
|
||||
```
|
||||
# 전체 파일 읽지 말고 키워드 검색
|
||||
PROBLEMS.md에서 "DnD" 검색 → 관련 행만
|
||||
CHANGELOG.md에서 "2026-02-05" 검색 → 해당 날짜만
|
||||
FILES.md에서 "렌더러" 검색 → 관련 파일만
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 저장 규칙 (쓰기)
|
||||
|
||||
### 저장 유형별 위치
|
||||
|
||||
| 요청 패턴 | 저장 위치 | 형식 |
|
||||
|----------|----------|------|
|
||||
| "오늘 작업 저장/정리해줘" | sessions/YYYY-MM-DD.md | 세션 템플릿 |
|
||||
| "이 결정 기록해줘" | decisions/NNN-제목.md | ADR 템플릿 |
|
||||
| "이 문제 해결 기록해줘" | PROBLEMS.md | 행 추가 |
|
||||
| "작업 내용 추가해줘" | CHANGELOG.md | 섹션 추가 |
|
||||
| "현재 상태 업데이트" | STATUS.md | 상태 수정 |
|
||||
| "기능 색인 추가해줘" | INDEX.md | 행 추가 |
|
||||
| "구현 계획 저장해줘" | PLAN.md "현재 구현 계획" | 섹션 교체 |
|
||||
|
||||
### 저장 후 필수 동기화
|
||||
|
||||
```
|
||||
저장 완료 후 항상:
|
||||
1. STATUS.md 업데이트 (진행 상태, 다음 작업)
|
||||
2. README.md "마지막 대화 요약" 업데이트 (1-2줄)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 분석/비교 규칙
|
||||
|
||||
### 비교 요청 시
|
||||
|
||||
```
|
||||
사용자: "@popdocs v4랑 v5 뭐가 달라?"
|
||||
|
||||
AI 행동:
|
||||
1. decisions/003-v5-grid-system.md 확인 (있으면)
|
||||
2. 없으면 CHANGELOG에서 관련 날짜 검색
|
||||
3. 비교표 형식으로 응답
|
||||
|
||||
응답 형식:
|
||||
| 항목 | v4 | v5 |
|
||||
|------|----|----|
|
||||
| 배치 | Flexbox | CSS Grid |
|
||||
| ... | ... | ... |
|
||||
```
|
||||
|
||||
### 분석 요청 시
|
||||
|
||||
```
|
||||
사용자: "@popdocs 이번 달 작업 분석해줘"
|
||||
|
||||
AI 행동:
|
||||
1. sessions/ 폴더에서 해당 기간 파일 목록
|
||||
2. 각 파일의 "요약" 섹션만 추출
|
||||
3. 종합 분석 제공
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 수정 규칙
|
||||
|
||||
### 문서 수정 요청 시
|
||||
|
||||
```
|
||||
사용자: "@popdocs STATUS 업데이트해줘"
|
||||
|
||||
AI 행동:
|
||||
1. STATUS.md 읽기
|
||||
2. 변경 내용 적용
|
||||
3. README.md 동기화 (마지막 대화 요약)
|
||||
4. 변경 내용 사용자에게 확인
|
||||
```
|
||||
|
||||
### 여러 문서 수정 시
|
||||
|
||||
```
|
||||
수정 순서:
|
||||
상세 문서 먼저 → STATUS.md → README.md
|
||||
(역방향: 진입점이 항상 최신 상태 유지)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 요약/보고서 규칙
|
||||
|
||||
### 요약 요청 시
|
||||
|
||||
```
|
||||
사용자: "@popdocs 이번 주 요약해줘"
|
||||
|
||||
AI 행동:
|
||||
1. sessions/ 해당 기간 파일 확인
|
||||
2. 각 파일의 "요약" + "완료" 섹션 추출
|
||||
3. 압축된 형식으로 응답
|
||||
|
||||
응답 형식:
|
||||
## 이번 주 요약 (02-01 ~ 02-05)
|
||||
| 날짜 | 주요 작업 |
|
||||
|------|----------|
|
||||
| 02-05 | v5 통합 완료 |
|
||||
| 02-04 | ... |
|
||||
```
|
||||
|
||||
### 보고서 생성 요청 시
|
||||
|
||||
```
|
||||
사용자: "@popdocs 진행 보고서 만들어줘"
|
||||
|
||||
AI 행동:
|
||||
1. STATUS.md (현재 상태)
|
||||
2. CHANGELOG.md (최근 작업)
|
||||
3. sessions/ (상세 기록)
|
||||
4. 보고서 형식으로 조합
|
||||
|
||||
→ 새 파일 생성하지 말고 응답으로 제공
|
||||
→ 필요시 사용자가 저장 요청
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 예외 처리
|
||||
|
||||
### 분류 불가 시
|
||||
|
||||
```
|
||||
사용자: "@popdocs 이거 저장해줘" (유형 불명확)
|
||||
|
||||
AI 행동:
|
||||
→ 사용자에게 질문: "어떤 유형으로 저장할까요?"
|
||||
- 오늘 작업 기록 (sessions/)
|
||||
- 문제-해결 기록 (PROBLEMS.md)
|
||||
- 결정 사항 (decisions/)
|
||||
- 변경 이력 (CHANGELOG.md)
|
||||
```
|
||||
|
||||
### 복합 요청 시
|
||||
|
||||
```
|
||||
사용자: "@popdocs 문제 해결했고 결정도 내렸어. 저장해줘"
|
||||
|
||||
AI 행동 (순서):
|
||||
1. PROBLEMS.md에 문제-해결 추가
|
||||
2. decisions/에 ADR 생성 (필요시)
|
||||
3. CHANGELOG.md에 섹션 추가
|
||||
4. STATUS.md 업데이트
|
||||
5. README.md 동기화
|
||||
```
|
||||
|
||||
### 전체 조회 요청 시
|
||||
|
||||
```
|
||||
사용자: "@popdocs 전체 히스토리 보여줘"
|
||||
|
||||
AI 행동:
|
||||
→ "기간을 지정해주시면 더 정확히 찾아드릴게요."
|
||||
- 예: "최근 1주일", "2월 작업", "v5 관련"
|
||||
|
||||
→ 기간 없이 강행 시: CHANGELOG.md 최근 5개 항목만
|
||||
```
|
||||
|
||||
### 파일 없음 시
|
||||
|
||||
```
|
||||
사용자: "@popdocs 어제 작업 보여줘" (sessions/어제.md 없음)
|
||||
|
||||
AI 행동:
|
||||
→ "어제 작업 기록이 없습니다. CHANGELOG.md에서 찾아볼까요?"
|
||||
```
|
||||
|
||||
### 키워드 검색 실패 시
|
||||
|
||||
```
|
||||
사용자: "@popdocs DnD 문제 찾아줘" (PROBLEMS.md에 없음)
|
||||
|
||||
AI 행동:
|
||||
→ "PROBLEMS.md에서 못 찾았습니다. 다른 곳도 검색할까요?"
|
||||
- CHANGELOG.md
|
||||
- INDEX.md
|
||||
- sessions/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 동기화 규칙
|
||||
|
||||
### 항상 동기화해야 하는 쌍
|
||||
|
||||
| 변경 문서 | 동기화 대상 |
|
||||
|----------|-----------|
|
||||
| sessions/ 생성 | STATUS.md (최근 세션) |
|
||||
| PROBLEMS.md 추가 | - |
|
||||
| decisions/ 생성 | STATUS.md (관련 결정), CHANGELOG.md |
|
||||
| CHANGELOG.md 추가 | STATUS.md (진행 상태) |
|
||||
| STATUS.md 수정 | README.md (마지막 요약) |
|
||||
| PLAN.md 구현 계획 수정 | STATUS.md (다음 작업) |
|
||||
|
||||
### 불일치 발견 시
|
||||
|
||||
```
|
||||
README.md와 STATUS.md 내용이 다르면:
|
||||
→ STATUS.md를 정본(正本)으로
|
||||
→ README.md를 STATUS.md 기준으로 업데이트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 정리 규칙
|
||||
|
||||
### 주기적 정리 (수동 요청 시)
|
||||
|
||||
| 대상 | 조건 | 조치 |
|
||||
|------|------|------|
|
||||
| sessions/ | 30일 이상 | archive/sessions/로 이동 |
|
||||
| PROBLEMS.md | 100행 초과 | 카테고리별 분리 검토 |
|
||||
| CHANGELOG.md | 연도 변경 | 이전 연도 archive/로 |
|
||||
|
||||
### 정리 요청 패턴
|
||||
|
||||
```
|
||||
사용자: "@popdocs 오래된 파일 정리해줘"
|
||||
|
||||
AI 행동:
|
||||
1. sessions/ 30일 이상 파일 목록 제시
|
||||
2. 사용자 확인 후 archive/로 이동
|
||||
3. 강제 삭제하지 않음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 템플릿
|
||||
|
||||
### 세션 기록 (sessions/YYYY-MM-DD.md)
|
||||
|
||||
```markdown
|
||||
# YYYY-MM-DD 작업 기록
|
||||
|
||||
## 요약
|
||||
(한 줄 요약 - 50자 이내)
|
||||
|
||||
## 완료
|
||||
- [x] 작업1
|
||||
- [x] 작업2
|
||||
|
||||
## 미완료
|
||||
- [ ] 작업3 (이유: ...)
|
||||
|
||||
## 중단점
|
||||
> (내일 이어서 할 때 바로 시작할 수 있는 정보)
|
||||
|
||||
## 대화 핵심
|
||||
- 키워드1: 설명
|
||||
- 키워드2: 설명
|
||||
|
||||
## 관련 링크
|
||||
- CHANGELOG: #YYYY-MM-DD
|
||||
- ADR: decisions/NNN (있으면)
|
||||
```
|
||||
|
||||
### 문제-해결 (PROBLEMS.md 행 추가)
|
||||
|
||||
```markdown
|
||||
| 문제 | 해결 | 날짜 | 키워드 |
|
||||
|------|------|------|--------|
|
||||
| (에러/문제 설명) | (해결 방법) | YYYY-MM-DD | 검색용 |
|
||||
```
|
||||
|
||||
### ADR (decisions/NNN-제목.md)
|
||||
|
||||
```markdown
|
||||
# ADR-NNN: 제목
|
||||
|
||||
**날짜**: YYYY-MM-DD
|
||||
**상태**: 채택됨
|
||||
|
||||
## 배경 (왜)
|
||||
(2-3문장)
|
||||
|
||||
## 결정 (무엇)
|
||||
(핵심 결정 사항)
|
||||
|
||||
## 대안
|
||||
| 옵션 | 장점 | 단점 | 결과 |
|
||||
|------|------|------|------|
|
||||
|
||||
## 교훈
|
||||
- (배운 점)
|
||||
```
|
||||
|
||||
### 구현 계획 (PLAN.md "현재 구현 계획" 교체)
|
||||
|
||||
```markdown
|
||||
### 대상: [기능명]
|
||||
|
||||
#### 구현 순서 (의존성 기반)
|
||||
1. [ ] 파일명 - 변경 내용 요약
|
||||
2. [ ] 파일명 - 변경 내용 요약
|
||||
|
||||
#### 파일별 변경 사항
|
||||
| # | 파일 (경로) | 작업 | 핵심 변경 | 주의사항 |
|
||||
|---|------------|------|----------|---------|
|
||||
| 1 | path/file.tsx (신규) | 생성 | 설명 | 주의 |
|
||||
| 2 | path/file.tsx (수정) | 수정 | 설명 | 주의 |
|
||||
|
||||
#### 함정 경고
|
||||
- (빠뜨리면 에러나는 것들)
|
||||
|
||||
#### 참조
|
||||
- 관련 문서/파일 경로
|
||||
```
|
||||
|
||||
**라이프사이클**:
|
||||
- 계획 수립 시: "현재 구현 계획" 섹션을 새 계획으로 **교체**
|
||||
- 코딩 중: 완료 항목 `[ ]` → `[x]`
|
||||
- 기능 완료 시: 다음 기능 계획으로 **교체** (이전 계획은 CHANGELOG에 기록됨)
|
||||
- 항상 1개만 존재
|
||||
|
||||
### CHANGELOG 섹션
|
||||
|
||||
```markdown
|
||||
## [YYYY-MM-DD] 제목
|
||||
|
||||
### 배경
|
||||
(왜 - 2-3문장)
|
||||
|
||||
### 작업
|
||||
- [x] 완료1
|
||||
- [ ] 미완료 → STATUS.md로
|
||||
|
||||
### 다음
|
||||
→ STATUS.md 참조
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 토큰 비용 가이드
|
||||
|
||||
| 문서 | 평균 줄 수 | 언제 읽나 |
|
||||
|------|-----------|----------|
|
||||
| README.md | ~60 | 항상 (진입점) |
|
||||
| STATUS.md | ~40 | 항상 (진입점) |
|
||||
| SAVE_RULES.md | ~350 | 저장/조회 요청 시 |
|
||||
| sessions/날짜.md | ~30 | 해당 날짜만 |
|
||||
| PROBLEMS.md | ~100 | 키워드 검색 |
|
||||
| INDEX.md | ~80 | 키워드 검색 |
|
||||
| CHANGELOG.md | ~600 | 날짜/키워드 검색 |
|
||||
| ARCHITECTURE.md | ~300 | 구조 파악 시 |
|
||||
| FILES.md | ~200 | 파일 찾을 때 |
|
||||
|
||||
**권장**: README + STATUS = ~100줄로 작업 시작
|
||||
|
||||
---
|
||||
|
||||
## 예시 워크플로우
|
||||
|
||||
### 1. 작업 시작
|
||||
|
||||
```
|
||||
사용자: "@popdocs 오늘 작업 시작하자"
|
||||
|
||||
AI:
|
||||
1. README.md 읽기 (60줄)
|
||||
2. STATUS.md 읽기 (40줄)
|
||||
3. 중단점 확인
|
||||
4. "중단점: PopCanvas.tsx line 76 타입 오류. 여기서 시작할까요?"
|
||||
```
|
||||
|
||||
### 2. 자료 검색
|
||||
|
||||
```
|
||||
사용자: "@popdocs DnD 관련 자료 찾아줘"
|
||||
|
||||
AI:
|
||||
1. PROBLEMS.md에서 "DnD" 검색
|
||||
2. INDEX.md에서 "DnD" 검색
|
||||
3. 관련 내용 종합해서 응답
|
||||
4. 전체 파일 읽지 않음
|
||||
```
|
||||
|
||||
### 3. 비교 요청
|
||||
|
||||
```
|
||||
사용자: "@popdocs v4랑 v5 비교해줘"
|
||||
|
||||
AI:
|
||||
1. decisions/003-v5-grid-system.md 확인
|
||||
2. "대안 검토" 섹션에서 비교표 추출
|
||||
3. 필요시 CHANGELOG 보충
|
||||
```
|
||||
|
||||
### 4. 작업 저장
|
||||
|
||||
```
|
||||
사용자: "@popdocs 오늘 작업 저장해줘"
|
||||
|
||||
AI:
|
||||
1. sessions/2026-02-05.md 생성
|
||||
2. STATUS.md 업데이트
|
||||
3. README.md "마지막 대화 요약" 업데이트
|
||||
4. "저장 완료. 내일 이어서 할 중단점도 기록했습니다."
|
||||
```
|
||||
|
||||
### 5. 불명확한 요청
|
||||
|
||||
```
|
||||
사용자: "@popdocs 이거 저장해줘"
|
||||
|
||||
AI:
|
||||
"어떤 유형으로 저장할까요?
|
||||
1. 오늘 작업 기록 (sessions/)
|
||||
2. 문제-해결 (PROBLEMS.md)
|
||||
3. 결정 사항 (decisions/)
|
||||
4. 변경 이력 (CHANGELOG.md)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*2025-2026 AI 컨텍스트 엔지니어링 기반*
|
||||
*Progressive Disclosure, Agentic Context Engineering, Token as Currency*
|
||||
236
popdocs/SPEC.md
236
popdocs/SPEC.md
|
|
@ -1,236 +0,0 @@
|
|||
# POP 기술 스펙
|
||||
|
||||
**버전: v5 (CSS Grid 기반)**
|
||||
|
||||
---
|
||||
|
||||
## v5 핵심 규칙
|
||||
|
||||
### 1. 그리드 시스템
|
||||
|
||||
| 모드 | 화면 너비 | 칸 수 | 대상 기기 |
|
||||
|------|----------|-------|----------|
|
||||
| mobile_portrait | ~479px | 4칸 | 아이폰 SE ~ 갤럭시 S (세로) |
|
||||
| mobile_landscape | 480~767px | 6칸 | 스마트폰 가로, 작은 태블릿 |
|
||||
| tablet_portrait | 768~1023px | 8칸 | iPad Mini ~ iPad Pro (세로) |
|
||||
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 가로 (기본) |
|
||||
|
||||
> **브레이크포인트 기준**: 실제 기기 CSS 뷰포트 너비 기반 (2026-02-06 재설계)
|
||||
|
||||
### 2. 위치 지정
|
||||
|
||||
```typescript
|
||||
interface PopGridPosition {
|
||||
col: number; // 시작 열 (1부터)
|
||||
row: number; // 시작 행 (1부터)
|
||||
colSpan: number; // 열 크기 (1~12)
|
||||
rowSpan: number; // 행 크기 (1~)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 브레이크포인트 설정
|
||||
|
||||
```typescript
|
||||
const GRID_BREAKPOINTS = {
|
||||
mobile_portrait: {
|
||||
columns: 4,
|
||||
rowHeight: 48,
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
maxWidth: 479, // 아이폰 SE (375px) ~ 갤럭시 S (360px)
|
||||
},
|
||||
mobile_landscape: {
|
||||
columns: 6,
|
||||
rowHeight: 44,
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
minWidth: 480,
|
||||
maxWidth: 767, // 스마트폰 가로
|
||||
},
|
||||
tablet_portrait: {
|
||||
columns: 8,
|
||||
rowHeight: 52,
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
minWidth: 768, // iPad Mini 세로 (768px)
|
||||
maxWidth: 1023,
|
||||
},
|
||||
tablet_landscape: {
|
||||
columns: 12,
|
||||
rowHeight: 56,
|
||||
gap: 12,
|
||||
padding: 24,
|
||||
minWidth: 1024, // iPad Pro 11 가로 (1194px), 12.9 가로 (1366px)
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 4. 세로 자동 확장
|
||||
|
||||
```typescript
|
||||
// 캔버스 높이 동적 계산
|
||||
const MIN_CANVAS_HEIGHT = 600; // 최소 높이 (px)
|
||||
const CANVAS_EXTRA_ROWS = 3; // 항상 유지되는 여유 행 수
|
||||
|
||||
const dynamicCanvasHeight = useMemo(() => {
|
||||
// 가장 아래 컴포넌트 위치 계산
|
||||
const maxRowEnd = visibleComps.reduce((max, comp) => {
|
||||
const rowEnd = pos.row + pos.rowSpan;
|
||||
return Math.max(max, rowEnd);
|
||||
}, 1);
|
||||
|
||||
// 여유 행 추가하여 높이 계산
|
||||
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
|
||||
return Math.max(MIN_CANVAS_HEIGHT, totalRows * rowHeight + padding);
|
||||
}, [layout.components, ...]);
|
||||
```
|
||||
|
||||
**특징**:
|
||||
- 디자이너: 세로 무한 확장 (컴포넌트 추가에 제한 없음)
|
||||
- 뷰어: 터치 스크롤로 아래 컴포넌트 접근 가능
|
||||
|
||||
---
|
||||
|
||||
## 데이터 구조
|
||||
|
||||
### v5 레이아웃
|
||||
|
||||
```typescript
|
||||
interface PopLayoutDataV5 {
|
||||
version: "pop-5.0";
|
||||
metadata: {
|
||||
screenId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
gridConfig: {
|
||||
defaultMode: GridMode;
|
||||
maxRows: number;
|
||||
};
|
||||
components: PopComponentDefinitionV5[];
|
||||
globalSettings: {
|
||||
backgroundColor: string;
|
||||
padding: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### v5 컴포넌트
|
||||
|
||||
```typescript
|
||||
interface PopComponentDefinitionV5 {
|
||||
id: string;
|
||||
type: PopComponentType; // "pop-label" | "pop-button" | ...
|
||||
label: string;
|
||||
gridPosition: PopGridPosition;
|
||||
config: PopComponentConfig;
|
||||
visibility: Record<GridMode, boolean>; // 모드별 표시/숨김
|
||||
modeOverrides?: Record<GridMode, PopModeOverrideV5>; // 모드별 오버라이드
|
||||
}
|
||||
```
|
||||
|
||||
### 컴포넌트 타입
|
||||
|
||||
```typescript
|
||||
type PopComponentType =
|
||||
| "pop-label" // 텍스트 라벨
|
||||
| "pop-button" // 버튼
|
||||
| "pop-input" // 입력 필드
|
||||
| "pop-select" // 선택 박스
|
||||
| "pop-grid" // 데이터 그리드
|
||||
| "pop-container"; // 컨테이너
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 크기 프리셋
|
||||
|
||||
### 터치 요소
|
||||
|
||||
| 요소 | 일반 | 산업용 |
|
||||
|------|-----|-------|
|
||||
| 버튼 높이 | 48px | 60px |
|
||||
| 입력창 높이 | 48px | 56px |
|
||||
| 터치 영역 | 48px | 60px |
|
||||
|
||||
### 폰트 (clamp)
|
||||
|
||||
| 용도 | 범위 | CSS |
|
||||
|------|-----|-----|
|
||||
| 본문 | 14-18px | `clamp(14px, 1.5vw, 18px)` |
|
||||
| 제목 | 18-28px | `clamp(18px, 2.5vw, 28px)` |
|
||||
|
||||
### 간격
|
||||
|
||||
| 이름 | 값 | 용도 |
|
||||
|------|---|-----|
|
||||
| sm | 8px | 요소 내부 |
|
||||
| md | 16px | 컴포넌트 간 |
|
||||
| lg | 24px | 섹션 간 |
|
||||
|
||||
---
|
||||
|
||||
## 반응형 원칙
|
||||
|
||||
```
|
||||
누르는 것 → 고정 (48px) - 버튼, 터치 영역
|
||||
읽는 것 → 범위 (clamp) - 텍스트
|
||||
담는 것 → 칸 (colSpan) - 컨테이너
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 위치 변환
|
||||
|
||||
12칸 기준으로 설계 → 다른 모드에서 자동 변환
|
||||
|
||||
```typescript
|
||||
// 12칸 → 4칸 변환 예시
|
||||
const ratio = 4 / 12; // = 0.333
|
||||
|
||||
original: { col: 1, colSpan: 6 } // 12칸에서 절반
|
||||
converted: { col: 1, colSpan: 2 } // 4칸에서 절반
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 컴포넌트가 얇게 보임
|
||||
|
||||
- **증상**: rowSpan이 적용 안됨
|
||||
- **원인**: gridTemplateRows 고정 px
|
||||
- **해결**: `1fr` 사용
|
||||
|
||||
### 모드 전환 안 됨
|
||||
|
||||
- **증상**: 화면 크기 변경해도 레이아웃 유지
|
||||
- **해결**: `detectGridMode()` 사용
|
||||
|
||||
### 겹침 발생
|
||||
|
||||
- **증상**: 컴포넌트끼리 겹침
|
||||
- **해결**: `resolveOverlaps()` 호출
|
||||
|
||||
---
|
||||
|
||||
## 타입 가드
|
||||
|
||||
```typescript
|
||||
// v5 레이아웃 판별
|
||||
function isV5Layout(data: any): data is PopLayoutDataV5 {
|
||||
return data?.version === "pop-5.0";
|
||||
}
|
||||
|
||||
// 사용 예시
|
||||
if (isV5Layout(savedData)) {
|
||||
setLayout(savedData);
|
||||
} else {
|
||||
setLayout(createEmptyPopLayoutV5());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*상세 아키텍처: [ARCHITECTURE.md](./ARCHITECTURE.md)*
|
||||
*파일 목록: [FILES.md](./FILES.md)*
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
# 현재 상태
|
||||
|
||||
> **마지막 업데이트**: 2026-02-06
|
||||
> **담당**: POP 화면 디자이너
|
||||
|
||||
---
|
||||
|
||||
## 진행 상태
|
||||
|
||||
| 단계 | 상태 | 설명 |
|
||||
|------|------|------|
|
||||
| v5 타입 정의 | 완료 | `pop-layout.ts` |
|
||||
| v5 렌더러 | 완료 | `PopRenderer.tsx` |
|
||||
| v5 캔버스 | 완료 | `PopCanvas.tsx` |
|
||||
| v5 편집 패널 | 완료 | `ComponentEditorPanel.tsx` |
|
||||
| v5 유틸리티 | 완료 | `gridUtils.ts` |
|
||||
| 레거시 삭제 | 완료 | v1~v4 코드, 데이터 |
|
||||
| 문서 정리 | 완료 | popdocs v5 기준 재정비 |
|
||||
| 컴포넌트 팔레트 | 완료 | `ComponentPalette.tsx` |
|
||||
| 드래그앤드롭 | 완료 | 스케일 보정, DND 상수 통합 |
|
||||
| 그리드 가이드 재설계 | 완료 | CSS Grid 기반 통합 |
|
||||
| 모드별 오버라이드 | 완료 | 위치/크기 모드별 저장 |
|
||||
| 화면 밖 컴포넌트 | 완료 | 오른쪽 패널 배치, 드래그로 복원 |
|
||||
| 숨김 기능 | 완료 | 모드별 숨김/숨김해제 |
|
||||
| 리사이즈 겹침 검사 | 완료 | 실시간 겹침 방지 |
|
||||
| Gap 프리셋 | 완료 | 좁게/보통/넓게 간격 조정 |
|
||||
| **자동 줄바꿈** | **완료** | col > maxCol → 맨 아래 배치 |
|
||||
| **검토 필요 시스템** | **완료** | 오버라이드 없으면 검토 알림 |
|
||||
| **브레이크포인트 재설계** | **완료** | 기기 기반 (479/767/1023px) |
|
||||
| **세로 자동 확장** | **완료** | 캔버스 높이 동적 계산 |
|
||||
| **그리드 셀 크기 강제 고정** | **완료** | gridTemplateRows로 행 높이 고정, overflow-hidden |
|
||||
|
||||
---
|
||||
|
||||
## 다음 작업 (우선순위)
|
||||
|
||||
1. **실제 컴포넌트 구현** (Phase 4)
|
||||
- pop-label, pop-button 등 실제 렌더링
|
||||
- 데이터 바인딩 연결
|
||||
|
||||
2. **워크플로우 연동**
|
||||
- 버튼 액션 연결
|
||||
- 화면 전환 로직
|
||||
|
||||
---
|
||||
|
||||
## 최근 주요 변경 (2026-02-06)
|
||||
|
||||
### 브레이크포인트 재설계
|
||||
| 모드 | 변경 전 | 변경 후 | 근거 |
|
||||
|------|--------|--------|------|
|
||||
| mobile_portrait | ~599px | ~479px | 스마트폰 세로 |
|
||||
| mobile_landscape | 600~839px | 480~767px | 스마트폰 가로 |
|
||||
| tablet_portrait | 840~1023px | 768~1023px | iPad Mini 포함 |
|
||||
| tablet_landscape | 1024px+ | 동일 | - |
|
||||
|
||||
### 세로 자동 확장
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 동적 캔버스 높이 | 컴포넌트 배치에 따라 자동 계산 |
|
||||
| 최소 높이 | 600px 보장 |
|
||||
| 여유 행 | 항상 3행 추가 |
|
||||
| 뷰어 스크롤 | 터치 스크롤로 아래 컴포넌트 접근 |
|
||||
|
||||
### v5.1 자동 줄바꿈 시스템
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 자동 줄바꿈 | col > maxCol인 컴포넌트를 맨 아래에 자동 배치 |
|
||||
| 정보 손실 방지 | 모든 컴포넌트가 항상 그리드 안에 표시됨 |
|
||||
| 검토 필요 알림 | 오버라이드 없으면 "검토 필요" 패널 표시 |
|
||||
| 검토 완료 | 편집하면 오버라이드 저장, 검토 필요에서 제거 |
|
||||
|
||||
### 기존 기능 유지 (2026-02-05 심야)
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 모드별 재배치 | 4/6/8/12칸 모드별로 컴포넌트 위치/크기 개별 저장 |
|
||||
| 자동 레이아웃 고정 | 드래그/리사이즈 시 자동으로 오버라이드 저장 |
|
||||
| 원본으로 되돌리기 | 오버라이드 삭제하여 자동 재배치로 복원 |
|
||||
| 숨김 기능 | 특정 모드에서 컴포넌트 의도적 숨김 (검토와 별개) |
|
||||
|
||||
---
|
||||
|
||||
## 알려진 문제
|
||||
|
||||
| 문제 | 상태 | 비고 |
|
||||
|------|------|------|
|
||||
| 타입 이름 불일치 | 해결됨 | V5 접미사 제거 |
|
||||
| SVG 격자 좌표 불일치 | 해결됨 | GridGuide 삭제, CSS Grid 통합 |
|
||||
| 드래그 좌표 계산 오류 | 해결됨 | 스케일 보정 적용 |
|
||||
| DND 타입 상수 불일치 | 해결됨 | constants/dnd.ts로 통합 |
|
||||
| 숨김 컴포넌트 드래그 안됨 | 해결됨 | 상태 업데이트 순서 수정 |
|
||||
| 그리드 범위 초과 에러 | 해결됨 | 드롭 위치 자동 조정 |
|
||||
| Expected drag drop context | 해결됨 | 뷰어 모드에서 일반 div 렌더링 |
|
||||
| Gap 프리셋 UI 안 보임 | 해결됨 | 그리드 라벨에 adjustedGap 적용 |
|
||||
| 화면 밖 컴포넌트 정보 손실 | 해결됨 | 자동 줄바꿈으로 항상 그리드 안에 배치 |
|
||||
| 뷰어 반응형 모드 불일치 | 해결됨 | detectGridMode() 사용으로 일관성 확보 |
|
||||
| hiddenComponentIds 중복 정의 | 해결됨 | 중복 useMemo 제거 |
|
||||
| 그리드 가이드 셀 크기 불균일 | 해결됨 | gridTemplateRows로 행 높이 강제 고정 |
|
||||
| Canvas/Renderer 행 수 불일치 | 해결됨 | 숨김 필터 통일, 여유행 +3 |
|
||||
| 디버깅 console.log 잔존 | 해결됨 | reviewComponents 내 삭제 |
|
||||
|
||||
---
|
||||
|
||||
## 최근 세션
|
||||
|
||||
| 날짜 | 요약 | 상세 |
|
||||
|------|------|------|
|
||||
| 2026-02-06 | 브레이크포인트 재설계, 세로 자동 확장, v5.1 자동 줄바꿈 | [sessions/2026-02-06.md](./sessions/2026-02-06.md) |
|
||||
| 2026-02-05 심야 | 반응형 레이아웃, 숨김 기능, 겹침 검사 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) |
|
||||
| 2026-02-05 저녁 | v5 통합, 그리드 가이드 재설계 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) |
|
||||
|
||||
---
|
||||
|
||||
## 관련 결정
|
||||
|
||||
| ADR | 제목 | 날짜 |
|
||||
|-----|------|------|
|
||||
| 006 | v5.1 자동 줄바꿈 + 검토 필요 시스템 | 2026-02-06 |
|
||||
| 005 | 브레이크포인트 재설계 (기기 기반) + 세로 자동 확장 | 2026-02-06 |
|
||||
| 004 | 그리드 가이드 CSS Grid 통합 | 2026-02-05 |
|
||||
| 003 | v5 CSS Grid 채택 | 2026-02-05 |
|
||||
| 001 | v4 제약조건 기반 | 2026-02-03 |
|
||||
|
||||
---
|
||||
|
||||
*전체 히스토리: [CHANGELOG.md](./CHANGELOG.md)*
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
# 워크플로우 프롬프트
|
||||
|
||||
> 각 작업 단계에서 AI에게 내리는 표준 프롬프트입니다.
|
||||
> 상황에 맞는 프롬프트를 복사해서 사용하세요.
|
||||
> `[괄호]` 안은 상황에 맞게 수정하세요.
|
||||
|
||||
---
|
||||
|
||||
## 한 번에 복사용
|
||||
|
||||
```
|
||||
===== 토의 중 개념 학습 =====
|
||||
지금 설명한 [개념명]을 우리 프로젝트 코드에서 실제 사용되는 예시로 보여줘.
|
||||
해당 코드가 없으면 어떤 문제가 생기는지 한 문장으로.
|
||||
|
||||
===== 계획 =====
|
||||
구현 계획서를 작성해줘.
|
||||
|
||||
포함할 것:
|
||||
1. 파일별 변경 사항 (추가/수정/삭제할 코드)
|
||||
2. 구현 순서 (의존성 기반)
|
||||
|
||||
사전 검증 (코딩 전에 반드시):
|
||||
1. 새로 추가할 변수/함수/타입 각각에 대해 해당 파일에서 Grep으로 동일 이름 검색
|
||||
2. 충돌 발견 시 "충돌: [이름] - [파일명] 라인 [X]에 기존 정의 있음" 보고
|
||||
3. 충돌 있으면 해결 방안 제시 (이름 변경 or 기존 코드 재사용)
|
||||
4. 계획서에 명시된 모든 함수/변수/타입을 리스트업하고 "어디서 정의, 어디서 사용" 매핑
|
||||
5. 사용처는 있는데 정의가 누락된 항목이 있으면 보고
|
||||
|
||||
주의사항:
|
||||
- 이 대화를 못 본 사람도 실행할 수 있을 정도로 구체적으로
|
||||
- 빠뜨리면 에러날 만한 함정을 명시적으로 경고해줘
|
||||
|
||||
문서 정리:
|
||||
- PLAN.md "현재 구현 계획" 섹션을 이 계획으로 교체해줘
|
||||
- STATUS.md "다음 작업"도 동기화해줘
|
||||
|
||||
===== 계획 이해 (선택) =====
|
||||
이 계획에서 가장 복잡한 변경 1개를 골라서,
|
||||
왜 이렇게 해야 하는지 한 문장으로 설명해줘.
|
||||
|
||||
===== 코딩 =====
|
||||
위 계획대로 코딩 진행해줘.
|
||||
|
||||
규칙:
|
||||
1. 각 파일 수정 전에 해당 파일을 먼저 전체 읽어
|
||||
2. 새로 추가할 변수명이 파일에 이미 존재하는지 Grep으로 확인
|
||||
3. 기존 코드에 동일 이름이 있으면 재사용하거나 명시적으로 삭제 후 새로 정의해
|
||||
4. 한 파일 완료할 때마다 린트 확인
|
||||
|
||||
각 파일 수정이 끝나면 이것만 알려줘:
|
||||
- 충돌 검사 결과
|
||||
- 추가한 import
|
||||
- 정의한 함수/변수
|
||||
- 이 파일에서 가장 핵심적인 변경 1개와 그 이유 (한 문장)
|
||||
|
||||
코딩 완료 후 자체 검증:
|
||||
- 새로 추가한 모든 변수/함수가 정의되어 있는가?
|
||||
- 동일 이름의 변수/함수가 파일 내에 2개 이상 존재하지 않는가?
|
||||
- import한 모든 것이 실제로 사용되는가?
|
||||
- 사용하는 모든 것이 import되어 있는가?
|
||||
- interface의 모든 props가 실제로 전달되는가?
|
||||
이상 없으면 완료 보고, 이상 있으면 수정 후 보고.
|
||||
|
||||
문서 정리:
|
||||
- PLAN.md "현재 구현 계획"에서 완료된 항목은 [ ] → [x]로 체크해줘
|
||||
|
||||
===== 새 세션 코딩 (다른 모델) =====
|
||||
@popdocs/ 의 README → STATUS → PLAN.md "현재 구현 계획" 순서로 읽고,
|
||||
계획대로 코딩을 진행해줘.
|
||||
|
||||
규칙:
|
||||
1. 각 파일 수정 전에 해당 파일을 먼저 전체 읽어
|
||||
2. 새로 추가할 변수명이 파일에 이미 존재하는지 Grep으로 확인
|
||||
3. 기존 코드에 동일 이름이 있으면 재사용하거나 명시적으로 삭제 후 새로 정의해
|
||||
4. 한 파일 완료할 때마다 린트 확인
|
||||
|
||||
각 파일 수정이 끝나면 이것만 알려줘:
|
||||
- 충돌 검사 결과
|
||||
- 추가한 import
|
||||
- 정의한 함수/변수
|
||||
- 이 파일에서 가장 핵심적인 변경 1개와 그 이유 (한 문장)
|
||||
|
||||
코딩 완료 후 자체 검증:
|
||||
- 새로 추가한 모든 변수/함수가 정의되어 있는가?
|
||||
- 동일 이름의 변수/함수가 파일 내에 2개 이상 존재하지 않는가?
|
||||
- import한 모든 것이 실제로 사용되는가?
|
||||
- 사용하는 모든 것이 import되어 있는가?
|
||||
- interface의 모든 props가 실제로 전달되는가?
|
||||
이상 없으면 완료 보고, 이상 있으면 수정 후 보고.
|
||||
|
||||
문서 정리:
|
||||
- PLAN.md "현재 구현 계획"에서 완료된 항목은 [ ] → [x]로 체크해줘
|
||||
|
||||
===== 검수 =====
|
||||
수정한 파일들을 검수해줘.
|
||||
|
||||
검증 항목:
|
||||
1. 린트 에러
|
||||
2. 새로 추가한 변수/함수가 중복 정의되지 않았는지 Grep 확인
|
||||
3. import한 것 중 사용 안 하는 것, 사용하는데 import 안 한 것
|
||||
4. interface에 정의된 props와 실제 전달되는 props 일치 여부
|
||||
|
||||
문제 발견 시:
|
||||
- 고치기 전에 해당 코드를 보여주고 어디가 잘못됐는지 표시해줘
|
||||
- 내가 확인한 다음에 고쳐줘
|
||||
|
||||
문서 정리:
|
||||
- 발견된 문제가 있으면 PROBLEMS.md에 추가할 내용을 미리 정리해둬
|
||||
|
||||
===== 수정 =====
|
||||
발견된 문제를 수정해줘.
|
||||
|
||||
수정 전에 먼저:
|
||||
1. 이 문제가 왜 발생했는지 원인 한 문장
|
||||
2. 다음에 같은 실수를 방지하려면 코딩할 때 뭘 확인했어야 하는지 한 문장
|
||||
그다음 수정 진행해.
|
||||
|
||||
문서 정리:
|
||||
- 수정한 내용을 PROBLEMS.md 형식(문제 | 해결 | 날짜 | 키워드)으로 정리해둬
|
||||
|
||||
===== 기록 =====
|
||||
작업 내용을 popdocs에 기록해줘.
|
||||
|
||||
업데이트 대상:
|
||||
- sessions/오늘날짜.md 생성
|
||||
- CHANGELOG.md 섹션 추가
|
||||
- STATUS.md 진행상태 업데이트
|
||||
- PLAN.md "현재 구현 계획"에서 완료 항목 최종 확인
|
||||
- README.md "마지막 대화 요약" 동기화
|
||||
- PROBLEMS.md에 발생한 문제-해결 추가 (있으면)
|
||||
- INDEX.md에 새로 추가된 기능/함수 색인 추가 (있으면)
|
||||
|
||||
추가로 "이번 작업에서 배운 것" 섹션을 포함해줘:
|
||||
- 새로 알게 된 기술 개념 (있으면)
|
||||
- 발생했던 에러와 원인 패턴 (있으면)
|
||||
- 다음에 비슷한 작업할 때 주의할 점 (있으면)
|
||||
없으면 생략.
|
||||
|
||||
===== 동기화 확인 =====
|
||||
popdocs 문서 간 동기화 상태를 확인해줘.
|
||||
|
||||
확인 항목:
|
||||
1. README.md "마지막 대화 요약"이 STATUS.md와 일치하는지
|
||||
2. STATUS.md "다음 작업"이 PLAN.md "현재 구현 계획"과 일치하는지
|
||||
3. PLAN.md 체크박스 상태가 실제 코드 변경과 일치하는지
|
||||
4. sessions/오늘날짜.md의 "완료" 항목이 CHANGELOG.md와 일치하는지
|
||||
|
||||
불일치 발견 시:
|
||||
- 어떤 문서의 어떤 부분이 다른지 보여줘
|
||||
- STATUS.md를 정본으로 맞춰줘
|
||||
|
||||
===== 주간 복습 (금요일) =====
|
||||
이번 주 작업 기록을 보고:
|
||||
1. 내가 "쉽게 설명해줘"라고 요청했던 개념 중 가장 중요한 3개
|
||||
2. 발생했던 에러 중 다시 만날 가능성이 높은 패턴 2개
|
||||
3. 각각을 한 문장 정의 + 우리 프로젝트에서 어디에 해당하는지
|
||||
정리해줘.
|
||||
|
||||
참조할 문서:
|
||||
- sessions/ 이번 주 파일들
|
||||
- PROBLEMS.md 이번 주 항목들
|
||||
- CHANGELOG.md 이번 주 섹션들
|
||||
|
||||
===== 병합 준비 (merge 전) =====
|
||||
[source-branch]를 [target-branch]에 병합하려고 해.
|
||||
|
||||
병합 전 점검해줘:
|
||||
1. 양쪽 브랜치의 최근 커밋 히스토리 비교 (git log --oneline --left-right [target]...[source])
|
||||
2. 충돌 예상 파일 목록 (git merge --no-commit --no-ff [source] 후 git diff --name-only --diff-filter=U)
|
||||
3. 충돌 예상 파일 중 규모가 큰 파일(500줄 이상) 식별 - 이 파일들은 특별 주의 대상
|
||||
4. 양쪽에서 동시에 수정한 파일 목록 (git diff --name-only [target]...[source])
|
||||
5. 삭제 vs 수정 충돌 가능성 (한쪽에서 삭제하고 다른 쪽에서 수정한 파일)
|
||||
|
||||
점검 후 위험도를 알려줘:
|
||||
- 높음: 같은 함수/컴포넌트를 양쪽에서 구조적으로 변경한 경우
|
||||
- 중간: 같은 파일이지만 다른 부분을 수정한 경우
|
||||
- 낮음: 서로 다른 파일만 수정한 경우
|
||||
|
||||
충돌 예상 파일이 있으면 각 파일별로:
|
||||
- 양쪽에서 무엇을 변경했는지 한 줄 요약
|
||||
- 어떤 쪽을 기준으로 병합해야 하는지 판단 근거
|
||||
|
||||
===== 병합 실행 (merge 중) =====
|
||||
병합을 진행해줘.
|
||||
|
||||
규칙:
|
||||
1. diff3 형식으로 충돌 표시 (git config merge.conflictstyle diff3)
|
||||
2. 충돌 파일 하나씩 순서대로 해결 - 의존성 낮은 파일부터
|
||||
3. 각 충돌 파일 해결 전에 반드시:
|
||||
- 공통 조상(base)을 확인하여 양쪽이 원래 코드에서 무엇을 변경했는지 파악
|
||||
- 양쪽 변경의 의도를 모두 보존할 수 있는지 판단
|
||||
- 한쪽만 선택해야 하면 그 이유를 명시
|
||||
4. 충돌 마커(<<<<<<, ======, >>>>>>)가 모두 제거되었는지 확인
|
||||
|
||||
각 충돌 파일 해결 후 보고:
|
||||
- 충돌 위치 (함수명/컴포넌트명)
|
||||
- 해결 방식: "양쪽 통합" / "ours 선택" / "theirs 선택" / "새로 작성"
|
||||
- 선택 이유 한 문장
|
||||
|
||||
===== 병합 후 시맨틱 검증 (merge 후 - 가장 중요) =====
|
||||
텍스트 충돌은 해결했지만, Git이 감지 못하는 시맨틱 충돌을 점검해줘.
|
||||
|
||||
검증 항목:
|
||||
1. 함수/변수 이름 변경 충돌: 한쪽에서 rename한 함수를 다른 쪽에서 기존 이름으로 호출하고 있지 않은지
|
||||
2. 타입/인터페이스 변경 충돌: 타입 필드가 변경/삭제되었는데 다른 쪽에서 해당 필드를 사용하는 코드가 추가되지 않았는지
|
||||
3. import 정합성: 병합 후 중복 import, 누락 import, 사용하지 않는 import 확인
|
||||
4. 함수 시그니처 충돌: 매개변수가 변경되었는데 호출부가 기존 시그니처를 사용하지 않는지
|
||||
5. 삭제된 코드 의존성: 한쪽에서 삭제한 함수/변수를 다른 쪽 새 코드가 참조하지 않는지
|
||||
6. 전역 상태/설정 변경: 설정값이 바뀌었는데 기존 값 기반 로직이 추가되지 않았는지
|
||||
|
||||
검증 방법:
|
||||
- TypeScript 타입 체크: npx tsc --noEmit
|
||||
- 빌드 확인: npm run build
|
||||
- 남은 충돌 마커: git diff --check
|
||||
- 병합으로 변경된 전체 diff: git diff HEAD~1..HEAD
|
||||
|
||||
문제 발견 시:
|
||||
- 파일명, 라인, 구체적인 문제를 보여줘
|
||||
- 수정 방안을 제시하되, 내 확인 후에 수정해줘
|
||||
|
||||
===== 병합 후 빌드/테스트 검증 =====
|
||||
병합 후 프로젝트가 정상 작동하는지 확인해줘.
|
||||
|
||||
순서:
|
||||
1. 남은 충돌 마커 검색: 프로젝트 전체에서 <<<<<<, ======, >>>>>> 검색
|
||||
2. TypeScript 컴파일: npx tsc --noEmit → 타입 에러 목록
|
||||
3. 프론트엔드 빌드: npm run build → 빌드 에러 목록
|
||||
4. 백엔드 빌드: npm run build (backend-node) → 빌드 에러 목록
|
||||
5. 린트 체크: 변경된 파일들에 대해 린트 확인
|
||||
|
||||
에러 발견 시 각각에 대해:
|
||||
- 에러 메시지 전문
|
||||
- 원인이 병합 때문인지, 기존 코드 문제인지 구분
|
||||
- 병합 때문이면 어떤 충돌 해결이 잘못되었는지 추적
|
||||
|
||||
===== 병합 완료 정리 =====
|
||||
병합이 완료되었어. 정리해줘.
|
||||
|
||||
정리 항목:
|
||||
1. 병합 요약: 어떤 브랜치에서 어떤 브랜치로, 총 충돌 파일 수, 해결 방식 통계
|
||||
2. 주의가 필요한 변경사항: 시맨틱 충돌 위험이 있었던 부분 목록
|
||||
3. 테스트가 필요한 기능: 병합으로 영향받은 기능 목록 (수동 테스트 대상)
|
||||
4. 커밋 메시지 작성: 병합 내용을 요약한 적절한 커밋 메시지 제안
|
||||
|
||||
문서 정리:
|
||||
- PROBLEMS.md에 병합 중 발견된 문제-해결 추가 (있으면)
|
||||
- CHANGELOG.md에 병합 내용 기록
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## popdocs 업데이트 시점 요약
|
||||
|
||||
| 단계 | 업데이트 대상 | 시점 |
|
||||
|------|-------------|------|
|
||||
| 계획 수립 | PLAN.md "현재 구현 계획", STATUS.md | 계획 확정 시 |
|
||||
| 코딩 중 | PLAN.md 완료 체크 `[x]` | 각 파일 완료 시 |
|
||||
| 검수 | PROBLEMS.md 내용 준비 | 문제 발견 시 |
|
||||
| 수정 | PROBLEMS.md 내용 준비 | 수정 완료 시 |
|
||||
| 병합 준비 | (응답으로 제공) | merge 시작 전 |
|
||||
| 병합 실행 | (충돌 해결 중) | merge 진행 중 |
|
||||
| 병합 시맨틱 검증 | (응답으로 제공) | 텍스트 충돌 해결 직후 |
|
||||
| 병합 빌드 검증 | (응답으로 제공) | 시맨틱 검증 후 |
|
||||
| 병합 완료 정리 | PROBLEMS.md, CHANGELOG.md | 병합 최종 완료 시 |
|
||||
| 기록 | sessions/, CHANGELOG, STATUS, README, PROBLEMS, INDEX | 작업 완료 시 |
|
||||
| 동기화 확인 | 전체 문서 간 불일치 점검 | 기록 직후 |
|
||||
| 주간 복습 | (응답으로 제공, 파일 저장은 선택) | 금요일 |
|
||||
|
||||
---
|
||||
|
||||
## 세션 분리 가이드
|
||||
|
||||
```
|
||||
[Opus 세션] 토의 + 계획
|
||||
→ popdocs 업데이트 (PLAN.md, STATUS.md)
|
||||
→ 세션 종료
|
||||
|
||||
[새 세션 - Sonnet/Opus] 코딩 + 검수 + 수정
|
||||
→ "@popdocs/ 읽고 PLAN.md 계획대로 진행해"
|
||||
→ 15건 이내로 완료
|
||||
→ 세션 종료
|
||||
|
||||
[새 세션 - 아무 모델] 기록 + 동기화 확인
|
||||
→ "기록" 프롬프트 → "동기화 확인" 프롬프트
|
||||
|
||||
[병합 세션 - Opus 권장] 브랜치 병합
|
||||
→ "병합 준비" 프롬프트 → 위험도 파악
|
||||
→ "병합 실행" 프롬프트 → 텍스트 충돌 해결
|
||||
→ "병합 후 시맨틱 검증" 프롬프트 → 숨은 버그 점검
|
||||
→ "병합 후 빌드/테스트 검증" 프롬프트 → 빌드 확인
|
||||
→ "병합 완료 정리" 프롬프트 → 기록 및 커밋
|
||||
```
|
||||
|
||||
**세션을 끊는 기준**:
|
||||
- 작업이 15건 이내로 끝나면 한 세션에서 끝까지 (끊을 필요 없음)
|
||||
- 대화가 15건을 넘어갈 것 같으면 세션 분리
|
||||
- 완전히 다른 작업으로 전환할 때
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-02-09*
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
# POP 레이아웃 canvasGrid.rows 버그 수정
|
||||
|
||||
## 문제점
|
||||
|
||||
### 1. 데이터 불일치
|
||||
- **DB에 저장된 데이터**: `canvasGrid.rowHeight: 20` (고정 픽셀)
|
||||
- **코드에서 기대하는 데이터**: `canvasGrid.rows: 24` (비율 기반)
|
||||
- **결과**: `rows`가 `undefined`로 인한 렌더링 오류
|
||||
|
||||
### 2. 타입 정의 불일치
|
||||
- **PopCanvas.tsx 타입**: `{ columns: number; rowHeight: number; gap: number }`
|
||||
- **실제 사용**: `canvasGrid.rows`로 계산
|
||||
- **결과**: 타입 안정성 저하
|
||||
|
||||
### 3. 렌더링 오류
|
||||
- **디자이너**: `rowHeight = resolution.height / undefined` → `NaN`
|
||||
- **뷰어**: `gridTemplateRows: repeat(undefined, 1fr)` → CSS 무효
|
||||
- **결과**: 섹션이 매우 작게 표시됨
|
||||
|
||||
---
|
||||
|
||||
## 수정 내용
|
||||
|
||||
### 1. ensureV2Layout 강화
|
||||
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||
|
||||
```typescript
|
||||
export const ensureV2Layout = (data: PopLayoutData): PopLayoutDataV2 => {
|
||||
let result: PopLayoutDataV2;
|
||||
|
||||
if (isV2Layout(data)) {
|
||||
result = data;
|
||||
} else if (isV1Layout(data)) {
|
||||
result = migrateV1ToV2(data);
|
||||
} else {
|
||||
console.warn("알 수 없는 레이아웃 버전, 빈 v2 레이아웃 생성");
|
||||
result = createEmptyPopLayoutV2();
|
||||
}
|
||||
|
||||
// ✅ canvasGrid.rows 보장 (구버전 데이터 호환)
|
||||
if (!result.settings.canvasGrid.rows) {
|
||||
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
|
||||
result.settings.canvasGrid = {
|
||||
...result.settings.canvasGrid,
|
||||
rows: DEFAULT_CANVAS_GRID.rows, // 24
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
```
|
||||
|
||||
**효과**: DB에서 로드한 구버전 데이터도 자동으로 `rows: 24` 보장
|
||||
|
||||
---
|
||||
|
||||
### 2. PopCanvas.tsx 타입 수정 및 fallback
|
||||
**파일**: `frontend/components/pop/designer/PopCanvas.tsx`
|
||||
|
||||
**타입 정의 수정**:
|
||||
```typescript
|
||||
interface DeviceFrameProps {
|
||||
canvasGrid: { columns: number; rows: number; gap: number }; // rowHeight → rows
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**fallback 추가**:
|
||||
```typescript
|
||||
// ✅ rows가 없으면 24 사용
|
||||
const rows = canvasGrid.rows || 24;
|
||||
const rowHeight = Math.floor(resolution.height / rows);
|
||||
```
|
||||
|
||||
**효과**:
|
||||
- 타입 일관성 확보
|
||||
- `NaN` 방지
|
||||
|
||||
---
|
||||
|
||||
### 3. PopLayoutRenderer.tsx fallback
|
||||
**파일**: `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx`
|
||||
|
||||
```typescript
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${canvasGrid.columns}, 1fr)`,
|
||||
// ✅ fallback 추가
|
||||
gridTemplateRows: `repeat(${canvasGrid.rows || 24}, 1fr)`,
|
||||
gap: `${canvasGrid.gap}px`,
|
||||
padding: `${canvasGrid.gap}px`,
|
||||
}}
|
||||
```
|
||||
|
||||
**효과**: 뷰어에서도 안전하게 렌더링
|
||||
|
||||
---
|
||||
|
||||
### 4. 백엔드 저장 로직 강화
|
||||
**파일**: `backend-node/src/services/screenManagementService.ts`
|
||||
|
||||
```typescript
|
||||
if (isV2) {
|
||||
dataToSave = {
|
||||
...layoutData,
|
||||
version: "pop-2.0",
|
||||
};
|
||||
|
||||
// ✅ canvasGrid.rows 검증 및 보정
|
||||
if (dataToSave.settings?.canvasGrid) {
|
||||
if (!dataToSave.settings.canvasGrid.rows) {
|
||||
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
|
||||
dataToSave.settings.canvasGrid.rows = 24;
|
||||
}
|
||||
// ✅ 구버전 rowHeight 필드 제거
|
||||
if (dataToSave.settings.canvasGrid.rowHeight) {
|
||||
console.warn("구버전 rowHeight 필드 제거");
|
||||
delete dataToSave.settings.canvasGrid.rowHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**효과**: 앞으로 저장되는 모든 데이터는 올바른 구조 보장
|
||||
|
||||
---
|
||||
|
||||
## 원칙 준수 여부
|
||||
|
||||
### 1. 데스크톱과 완전 분리 ✅
|
||||
- POP 전용 파일만 수정
|
||||
- 데스크톱 코드 0% 영향
|
||||
|
||||
### 2. 4모드 반응형 디자인 ✅
|
||||
- 변경 없음
|
||||
|
||||
### 3. 비율 기반 그리드 시스템 ✅
|
||||
- **오히려 원칙을 바로잡는 수정**
|
||||
- 고정 픽셀(`rowHeight`) → 비율(`rows`) 강제
|
||||
|
||||
---
|
||||
|
||||
## 해결된 문제
|
||||
|
||||
| 문제 | 수정 전 | 수정 후 |
|
||||
|------|---------|---------|
|
||||
| 섹션 크기 | 매우 작게 표시 | 정상 크기 (24x24 그리드) |
|
||||
| 디자이너 렌더링 | `NaN` 오류 | 정상 계산 |
|
||||
| 뷰어 렌더링 | CSS 무효 | 비율 기반 렌더링 |
|
||||
| 타입 안정성 | `rowHeight` vs `rows` 불일치 | `rows`로 통일 |
|
||||
| 구버전 데이터 | 호환 불가 | 자동 보정 |
|
||||
|
||||
---
|
||||
|
||||
## 테스트 방법
|
||||
|
||||
### 1. 기존 화면 확인 (screen_id: 3884)
|
||||
```bash
|
||||
# 디자이너 접속
|
||||
http://localhost:9771/screen-management/pop-designer/3884
|
||||
|
||||
# 저장 후 뷰어 확인
|
||||
http://localhost:9771/pop/screens/3884
|
||||
```
|
||||
|
||||
**기대 결과**:
|
||||
- 섹션이 화면 전체 크기로 정상 표시
|
||||
- 가로/세로 모드 전환 시 비율 유지
|
||||
|
||||
### 2. 새로운 화면 생성
|
||||
- POP 디자이너에서 새 화면 생성
|
||||
- 섹션 추가 및 배치
|
||||
- 저장 후 DB 확인
|
||||
|
||||
**DB 확인**:
|
||||
```sql
|
||||
SELECT
|
||||
screen_id,
|
||||
layout_data->'settings'->'canvasGrid' as canvas_grid
|
||||
FROM screen_layouts_pop
|
||||
WHERE screen_id = 3884;
|
||||
```
|
||||
|
||||
**기대 결과**:
|
||||
```json
|
||||
{
|
||||
"gap": 4,
|
||||
"rows": 24,
|
||||
"columns": 24
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 추가 조치 사항
|
||||
|
||||
### 1. 기존 DB 데이터 마이그레이션 (선택)
|
||||
만약 프론트엔드 자동 보정이 아닌 DB 마이그레이션을 원한다면:
|
||||
|
||||
```sql
|
||||
UPDATE screen_layouts_pop
|
||||
SET layout_data = jsonb_set(
|
||||
jsonb_set(
|
||||
layout_data,
|
||||
'{settings,canvasGrid,rows}',
|
||||
'24'
|
||||
),
|
||||
'{settings,canvasGrid}',
|
||||
(layout_data->'settings'->'canvasGrid') - 'rowHeight'
|
||||
)
|
||||
WHERE layout_data->'settings'->'canvasGrid'->>'rows' IS NULL
|
||||
AND layout_data->>'version' = 'pop-2.0';
|
||||
```
|
||||
|
||||
### 2. 모드별 컴포넌트 위치 반대 문제
|
||||
**별도 이슈**: `activeModeKey` 상태 관리 점검 필요
|
||||
- DeviceFrame 클릭 시 모드 전환
|
||||
- 저장 시 올바른 `modeKey` 전달 확인
|
||||
|
||||
---
|
||||
|
||||
## 결론
|
||||
|
||||
✅ **원칙 준수**: 데스크톱 분리, 4모드 반응형 유지
|
||||
✅ **비율 기반 강제**: 고정 픽셀 제거
|
||||
✅ **하위 호환**: 구버전 데이터 자동 보정
|
||||
✅ **안정성 향상**: 타입 일관성 확보
|
||||
|
|
@ -1,389 +0,0 @@
|
|||
# POP 컴포넌트 로드맵
|
||||
|
||||
## 큰 그림: 3단계 접근
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 1단계: 기초 블록 2단계: 조합 블록 3단계: 완성 화면 │
|
||||
│ ─────────────── ─────────────── ─────────────── │
|
||||
│ │
|
||||
│ [버튼] [입력창] [폼 그룹] [작업지시 화면] │
|
||||
│ [아이콘] [라벨] → [카드] → [실적입력 화면] │
|
||||
│ [뱃지] [로딩] [리스트] [모니터링 대시보드] │
|
||||
│ [테이블] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1단계: 기초 블록 (Primitive)
|
||||
|
||||
가장 작은 단위. 다른 곳에서 재사용됩니다.
|
||||
|
||||
### 필수 기초 블록
|
||||
|
||||
| 컴포넌트 | 역할 | 우선순위 |
|
||||
|---------|------|---------|
|
||||
| `PopButton` | 모든 버튼 | 1 |
|
||||
| `PopInput` | 텍스트 입력 | 1 |
|
||||
| `PopLabel` | 라벨/제목 | 1 |
|
||||
| `PopIcon` | 아이콘 표시 | 1 |
|
||||
| `PopBadge` | 상태 뱃지 | 2 |
|
||||
| `PopLoading` | 로딩 스피너 | 2 |
|
||||
| `PopDivider` | 구분선 | 3 |
|
||||
|
||||
### PopButton 예시
|
||||
|
||||
```typescript
|
||||
interface PopButtonProps {
|
||||
children: React.ReactNode;
|
||||
variant: "primary" | "secondary" | "danger" | "success";
|
||||
size: "sm" | "md" | "lg" | "xl";
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
icon?: string;
|
||||
fullWidth?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// 사용
|
||||
<PopButton variant="primary" size="lg">
|
||||
작업 완료
|
||||
</PopButton>
|
||||
```
|
||||
|
||||
### PopInput 예시
|
||||
|
||||
```typescript
|
||||
interface PopInputProps {
|
||||
type: "text" | "number" | "date" | "time";
|
||||
value: string | number;
|
||||
onChange: (value: string | number) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
size: "md" | "lg"; // POP은 lg 기본
|
||||
}
|
||||
|
||||
// 사용
|
||||
<PopInput
|
||||
type="number"
|
||||
label="수량"
|
||||
size="lg"
|
||||
value={qty}
|
||||
onChange={setQty}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2단계: 조합 블록 (Compound)
|
||||
|
||||
기초 블록을 조합한 중간 단위.
|
||||
|
||||
### 조합 블록 목록
|
||||
|
||||
| 컴포넌트 | 구성 | 용도 |
|
||||
|---------|------|-----|
|
||||
| `PopFormField` | Label + Input + Error | 폼 입력 그룹 |
|
||||
| `PopCard` | Container + Header + Body | 정보 카드 |
|
||||
| `PopListItem` | Container + Content + Action | 리스트 항목 |
|
||||
| `PopNumberPad` | Grid + Buttons | 숫자 입력 |
|
||||
| `PopStatusBox` | Icon + Label + Value | 상태 표시 |
|
||||
|
||||
### PopFormField 예시
|
||||
|
||||
```typescript
|
||||
// 기초 블록 조합
|
||||
function PopFormField({ label, required, error, children }) {
|
||||
return (
|
||||
<div className="pop-form-field">
|
||||
<PopLabel required={required}>{label}</PopLabel>
|
||||
{children}
|
||||
{error && <span className="error">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 사용
|
||||
<PopFormField label="품번" required error={errors.itemCode}>
|
||||
<PopInput type="text" value={itemCode} onChange={setItemCode} />
|
||||
</PopFormField>
|
||||
```
|
||||
|
||||
### PopCard 예시
|
||||
|
||||
```typescript
|
||||
function PopCard({ title, badge, children, onClick }) {
|
||||
return (
|
||||
<div className="pop-card" onClick={onClick}>
|
||||
<div className="pop-card-header">
|
||||
<PopLabel size="lg">{title}</PopLabel>
|
||||
{badge && <PopBadge>{badge}</PopBadge>}
|
||||
</div>
|
||||
<div className="pop-card-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 사용
|
||||
<PopCard title="작업지시 #1234" badge="진행중">
|
||||
<p>목표 수량: 100개</p>
|
||||
<p>완료 수량: 45개</p>
|
||||
</PopCard>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3단계: 복합 컴포넌트 (Complex)
|
||||
|
||||
비즈니스 로직이 포함된 완성형.
|
||||
|
||||
### 복합 컴포넌트 목록
|
||||
|
||||
| 컴포넌트 | 기능 | 데이터 |
|
||||
|---------|------|-------|
|
||||
| `PopDataTable` | 대량 데이터 표시/편집 | API 연동 |
|
||||
| `PopCardList` | 카드 형태 리스트 | API 연동 |
|
||||
| `PopBarcodeScanner` | 바코드/QR 스캔 | 카메라/외부장치 |
|
||||
| `PopKpiGauge` | KPI 게이지 | 실시간 데이터 |
|
||||
| `PopAlarmList` | 알람 목록 | 웹소켓 |
|
||||
| `PopProcessFlow` | 공정 흐름도 | 공정 데이터 |
|
||||
|
||||
### PopDataTable 예시
|
||||
|
||||
```typescript
|
||||
interface PopDataTableProps {
|
||||
// 데이터
|
||||
data: any[];
|
||||
columns: Column[];
|
||||
|
||||
// 기능
|
||||
selectable?: boolean;
|
||||
editable?: boolean;
|
||||
sortable?: boolean;
|
||||
|
||||
// 반응형 (자동)
|
||||
responsiveColumns?: {
|
||||
tablet: string[];
|
||||
mobile: string[];
|
||||
};
|
||||
|
||||
// 이벤트
|
||||
onRowClick?: (row: any) => void;
|
||||
onSelectionChange?: (selected: any[]) => void;
|
||||
}
|
||||
|
||||
// 사용
|
||||
<PopDataTable
|
||||
data={workOrders}
|
||||
columns={[
|
||||
{ key: "orderNo", label: "지시번호" },
|
||||
{ key: "itemName", label: "품명" },
|
||||
{ key: "qty", label: "수량", align: "right" },
|
||||
{ key: "status", label: "상태" },
|
||||
]}
|
||||
responsiveColumns={{
|
||||
tablet: ["orderNo", "itemName", "qty", "status"],
|
||||
mobile: ["orderNo", "qty"], // 모바일은 2개만
|
||||
}}
|
||||
onRowClick={(row) => openDetail(row.id)}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 개발 순서 제안
|
||||
|
||||
### Phase 1: 기초 (1-2주)
|
||||
|
||||
```
|
||||
Week 1:
|
||||
- PopButton (모든 버튼의 기반)
|
||||
- PopInput (모든 입력의 기반)
|
||||
- PopLabel
|
||||
- PopIcon
|
||||
|
||||
Week 2:
|
||||
- PopBadge
|
||||
- PopLoading
|
||||
- PopDivider
|
||||
```
|
||||
|
||||
### Phase 2: 조합 (2-3주)
|
||||
|
||||
```
|
||||
Week 3:
|
||||
- PopFormField (폼의 기본 단위)
|
||||
- PopCard (카드의 기본 단위)
|
||||
|
||||
Week 4:
|
||||
- PopListItem
|
||||
- PopStatusBox
|
||||
- PopNumberPad
|
||||
|
||||
Week 5:
|
||||
- PopModal
|
||||
- PopToast
|
||||
```
|
||||
|
||||
### Phase 3: 복합 (3-4주)
|
||||
|
||||
```
|
||||
Week 6-7:
|
||||
- PopDataTable (가장 복잡)
|
||||
- PopCardList
|
||||
|
||||
Week 8-9:
|
||||
- PopBarcodeScanner
|
||||
- PopKpiGauge
|
||||
- PopAlarmList
|
||||
- PopProcessFlow
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 설계 원칙
|
||||
|
||||
### 1. 크기는 외부에서 제어
|
||||
|
||||
```typescript
|
||||
// 좋음: 크기를 props로 받음
|
||||
<PopButton size="lg">확인</PopButton>
|
||||
|
||||
// 나쁨: 내부에서 크기 고정
|
||||
<button style={{ height: "48px" }}>확인</button>
|
||||
```
|
||||
|
||||
### 2. 최소 크기는 내부에서 보장
|
||||
|
||||
```typescript
|
||||
// 컴포넌트 내부
|
||||
const styles = {
|
||||
minHeight: 48, // 터치 최소 크기 보장
|
||||
minWidth: 80,
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 반응형은 자동
|
||||
|
||||
```typescript
|
||||
// 좋음: 화면 크기에 따라 자동 조절
|
||||
<PopFormField label="이름">
|
||||
<PopInput />
|
||||
</PopFormField>
|
||||
|
||||
// 나쁨: 모드별로 다른 컴포넌트
|
||||
{isMobile ? <MobileInput /> : <TabletInput />}
|
||||
```
|
||||
|
||||
### 4. 데이터와 UI 분리
|
||||
|
||||
```typescript
|
||||
// 좋음: 데이터 로직은 훅으로
|
||||
const { data, loading, error } = useWorkOrders();
|
||||
|
||||
<PopDataTable data={data} loading={loading} />
|
||||
|
||||
// 나쁨: 컴포넌트 안에서 fetch
|
||||
function PopDataTable() {
|
||||
useEffect(() => {
|
||||
fetch('/api/work-orders')...
|
||||
}, []);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 폴더 구조 제안
|
||||
|
||||
```
|
||||
frontend/components/pop/
|
||||
├── primitives/ # 1단계: 기초 블록
|
||||
│ ├── PopButton.tsx
|
||||
│ ├── PopInput.tsx
|
||||
│ ├── PopLabel.tsx
|
||||
│ ├── PopIcon.tsx
|
||||
│ ├── PopBadge.tsx
|
||||
│ ├── PopLoading.tsx
|
||||
│ └── index.ts
|
||||
│
|
||||
├── compounds/ # 2단계: 조합 블록
|
||||
│ ├── PopFormField.tsx
|
||||
│ ├── PopCard.tsx
|
||||
│ ├── PopListItem.tsx
|
||||
│ ├── PopNumberPad.tsx
|
||||
│ ├── PopStatusBox.tsx
|
||||
│ └── index.ts
|
||||
│
|
||||
├── complex/ # 3단계: 복합 컴포넌트
|
||||
│ ├── PopDataTable/
|
||||
│ │ ├── PopDataTable.tsx
|
||||
│ │ ├── PopTableHeader.tsx
|
||||
│ │ ├── PopTableRow.tsx
|
||||
│ │ └── index.ts
|
||||
│ ├── PopCardList/
|
||||
│ ├── PopBarcodeScanner/
|
||||
│ └── index.ts
|
||||
│
|
||||
├── hooks/ # 공용 훅
|
||||
│ ├── usePopTheme.ts
|
||||
│ ├── useResponsiveSize.ts
|
||||
│ └── useTouchFeedback.ts
|
||||
│
|
||||
└── styles/ # 공용 스타일
|
||||
├── pop-variables.css
|
||||
└── pop-base.css
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 스타일 변수
|
||||
|
||||
```css
|
||||
/* pop-variables.css */
|
||||
|
||||
:root {
|
||||
/* 터치 크기 */
|
||||
--pop-touch-min: 48px;
|
||||
--pop-touch-industrial: 60px;
|
||||
|
||||
/* 폰트 크기 */
|
||||
--pop-font-body: clamp(14px, 1.5vw, 18px);
|
||||
--pop-font-heading: clamp(18px, 2.5vw, 28px);
|
||||
--pop-font-caption: clamp(12px, 1vw, 14px);
|
||||
|
||||
/* 간격 */
|
||||
--pop-gap-sm: 8px;
|
||||
--pop-gap-md: 16px;
|
||||
--pop-gap-lg: 24px;
|
||||
|
||||
/* 색상 */
|
||||
--pop-primary: #2563eb;
|
||||
--pop-success: #16a34a;
|
||||
--pop-warning: #f59e0b;
|
||||
--pop-danger: #dc2626;
|
||||
|
||||
/* 고대비 (야외용) */
|
||||
--pop-high-contrast-bg: #000000;
|
||||
--pop-high-contrast-fg: #ffffff;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. **기초 블록부터 시작**: PopButton, PopInput 먼저 만들기
|
||||
2. **스토리북 설정**: 컴포넌트별 문서화
|
||||
3. **테스트**: 터치 크기, 반응형 확인
|
||||
4. **디자이너 연동**: v4 레이아웃 시스템과 통합
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-02-03*
|
||||
|
|
@ -1,763 +0,0 @@
|
|||
# POP 그리드 시스템 코딩 계획
|
||||
|
||||
> 작성일: 2026-02-05
|
||||
> 상태: 코딩 준비 완료
|
||||
|
||||
---
|
||||
|
||||
## 작업 목록
|
||||
|
||||
```
|
||||
Phase 5.1: 타입 정의 ─────────────────────────────
|
||||
[ ] 1. v5 타입 정의 (PopLayoutDataV5, PopGridConfig 등)
|
||||
[ ] 2. 브레이크포인트 상수 정의
|
||||
[ ] 3. v5 생성/변환 함수
|
||||
|
||||
Phase 5.2: 그리드 렌더러 ─────────────────────────
|
||||
[ ] 4. PopGridRenderer.tsx 생성
|
||||
[ ] 5. 위치 변환 로직 (12칸→4칸)
|
||||
|
||||
Phase 5.3: 디자이너 UI ───────────────────────────
|
||||
[ ] 6. PopCanvasV5.tsx 생성
|
||||
[ ] 7. 드래그 스냅 기능
|
||||
[ ] 8. ComponentEditorPanelV5.tsx
|
||||
|
||||
Phase 5.4: 통합 ──────────────────────────────────
|
||||
[ ] 9. 자동 변환 알고리즘
|
||||
[ ] 10. PopDesigner.tsx 통합
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5.1: 타입 정의
|
||||
|
||||
### 작업 1: v5 타입 정의
|
||||
|
||||
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||
|
||||
**추가할 코드**:
|
||||
|
||||
```typescript
|
||||
// ========================================
|
||||
// v5.0 그리드 기반 레이아웃
|
||||
// ========================================
|
||||
// 핵심: CSS Grid로 정확한 위치 지정
|
||||
// - 열/행 좌표로 배치 (col, row)
|
||||
// - 칸 단위 크기 (colSpan, rowSpan)
|
||||
|
||||
/**
|
||||
* v5 레이아웃 (그리드 기반)
|
||||
*/
|
||||
export interface PopLayoutDataV5 {
|
||||
version: "pop-5.0";
|
||||
|
||||
// 그리드 설정
|
||||
gridConfig: PopGridConfig;
|
||||
|
||||
// 컴포넌트 정의 (ID → 정의)
|
||||
components: Record<string, PopComponentDefinitionV5>;
|
||||
|
||||
// 데이터 흐름 (기존과 동일)
|
||||
dataFlow: PopDataFlow;
|
||||
|
||||
// 전역 설정
|
||||
settings: PopGlobalSettingsV5;
|
||||
|
||||
// 메타데이터
|
||||
metadata?: PopLayoutMetadata;
|
||||
|
||||
// 모드별 오버라이드 (위치 변경용)
|
||||
overrides?: {
|
||||
mobile_portrait?: PopModeOverrideV5;
|
||||
mobile_landscape?: PopModeOverrideV5;
|
||||
tablet_portrait?: PopModeOverrideV5;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리드 설정
|
||||
*/
|
||||
export interface PopGridConfig {
|
||||
// 행 높이 (px) - 1행의 기본 높이
|
||||
rowHeight: number; // 기본 48px
|
||||
|
||||
// 간격 (px)
|
||||
gap: number; // 기본 8px
|
||||
|
||||
// 패딩 (px)
|
||||
padding: number; // 기본 16px
|
||||
}
|
||||
|
||||
/**
|
||||
* v5 컴포넌트 정의
|
||||
*/
|
||||
export interface PopComponentDefinitionV5 {
|
||||
id: string;
|
||||
type: PopComponentType;
|
||||
label?: string;
|
||||
|
||||
// 위치 (열/행 좌표) - 기본 모드(태블릿 가로 12칸) 기준
|
||||
position: PopGridPosition;
|
||||
|
||||
// 모드별 표시/숨김
|
||||
visibility?: {
|
||||
tablet_landscape?: boolean;
|
||||
tablet_portrait?: boolean;
|
||||
mobile_landscape?: boolean;
|
||||
mobile_portrait?: boolean;
|
||||
};
|
||||
|
||||
// 기존 속성
|
||||
dataBinding?: PopDataBinding;
|
||||
style?: PopStylePreset;
|
||||
config?: PopComponentConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리드 위치
|
||||
*/
|
||||
export interface PopGridPosition {
|
||||
col: number; // 시작 열 (1부터, 최대 12)
|
||||
row: number; // 시작 행 (1부터)
|
||||
colSpan: number; // 차지할 열 수 (1~12)
|
||||
rowSpan: number; // 차지할 행 수 (1~)
|
||||
}
|
||||
|
||||
/**
|
||||
* v5 전역 설정
|
||||
*/
|
||||
export interface PopGlobalSettingsV5 {
|
||||
// 터치 최소 크기 (px)
|
||||
touchTargetMin: number; // 기본 48
|
||||
|
||||
// 모드
|
||||
mode: "normal" | "industrial";
|
||||
}
|
||||
|
||||
/**
|
||||
* v5 모드별 오버라이드
|
||||
*/
|
||||
export interface PopModeOverrideV5 {
|
||||
// 컴포넌트별 위치 오버라이드
|
||||
positions?: Record<string, Partial<PopGridPosition>>;
|
||||
|
||||
// 컴포넌트별 숨김
|
||||
hidden?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### 작업 2: 브레이크포인트 상수
|
||||
|
||||
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||
|
||||
```typescript
|
||||
// ========================================
|
||||
// 그리드 브레이크포인트
|
||||
// ========================================
|
||||
|
||||
export type GridMode =
|
||||
| "mobile_portrait"
|
||||
| "mobile_landscape"
|
||||
| "tablet_portrait"
|
||||
| "tablet_landscape";
|
||||
|
||||
export const GRID_BREAKPOINTS: Record<GridMode, {
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
columns: number;
|
||||
rowHeight: number;
|
||||
gap: number;
|
||||
padding: number;
|
||||
label: string;
|
||||
}> = {
|
||||
// 4~6인치 모바일 세로
|
||||
mobile_portrait: {
|
||||
maxWidth: 599,
|
||||
columns: 4,
|
||||
rowHeight: 40,
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
label: "모바일 세로 (4칸)",
|
||||
},
|
||||
|
||||
// 6~8인치 모바일 가로 / 작은 태블릿
|
||||
mobile_landscape: {
|
||||
minWidth: 600,
|
||||
maxWidth: 839,
|
||||
columns: 6,
|
||||
rowHeight: 44,
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
label: "모바일 가로 (6칸)",
|
||||
},
|
||||
|
||||
// 8~10인치 태블릿 세로
|
||||
tablet_portrait: {
|
||||
minWidth: 840,
|
||||
maxWidth: 1023,
|
||||
columns: 8,
|
||||
rowHeight: 48,
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
label: "태블릿 세로 (8칸)",
|
||||
},
|
||||
|
||||
// 10~14인치 태블릿 가로 (기본)
|
||||
tablet_landscape: {
|
||||
minWidth: 1024,
|
||||
columns: 12,
|
||||
rowHeight: 48,
|
||||
gap: 16,
|
||||
padding: 24,
|
||||
label: "태블릿 가로 (12칸)",
|
||||
},
|
||||
};
|
||||
|
||||
// 기본 모드
|
||||
export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape";
|
||||
|
||||
// 뷰포트 너비로 모드 감지
|
||||
export function detectGridMode(viewportWidth: number): GridMode {
|
||||
if (viewportWidth < 600) return "mobile_portrait";
|
||||
if (viewportWidth < 840) return "mobile_landscape";
|
||||
if (viewportWidth < 1024) return "tablet_portrait";
|
||||
return "tablet_landscape";
|
||||
}
|
||||
```
|
||||
|
||||
### 작업 3: v5 생성/변환 함수
|
||||
|
||||
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||
|
||||
```typescript
|
||||
// ========================================
|
||||
// v5 유틸리티 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 빈 v5 레이아웃 생성
|
||||
*/
|
||||
export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
|
||||
version: "pop-5.0",
|
||||
gridConfig: {
|
||||
rowHeight: 48,
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
},
|
||||
components: {},
|
||||
dataFlow: { connections: [] },
|
||||
settings: {
|
||||
touchTargetMin: 48,
|
||||
mode: "normal",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* v5 레이아웃 여부 확인
|
||||
*/
|
||||
export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
|
||||
return layout?.version === "pop-5.0";
|
||||
};
|
||||
|
||||
/**
|
||||
* v5 컴포넌트 정의 생성
|
||||
*/
|
||||
export const createComponentDefinitionV5 = (
|
||||
id: string,
|
||||
type: PopComponentType,
|
||||
position: PopGridPosition,
|
||||
label?: string
|
||||
): PopComponentDefinitionV5 => ({
|
||||
id,
|
||||
type,
|
||||
label,
|
||||
position,
|
||||
});
|
||||
|
||||
/**
|
||||
* 컴포넌트 타입별 기본 크기 (칸 단위)
|
||||
*/
|
||||
export const DEFAULT_COMPONENT_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
||||
"pop-field": { colSpan: 3, rowSpan: 1 },
|
||||
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-list": { colSpan: 12, rowSpan: 4 },
|
||||
"pop-indicator": { colSpan: 3, rowSpan: 2 },
|
||||
"pop-scanner": { colSpan: 6, rowSpan: 2 },
|
||||
"pop-numpad": { colSpan: 4, rowSpan: 5 },
|
||||
"pop-spacer": { colSpan: 1, rowSpan: 1 },
|
||||
"pop-break": { colSpan: 12, rowSpan: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* v4 → v5 마이그레이션
|
||||
*/
|
||||
export const migrateV4ToV5 = (layoutV4: PopLayoutDataV4): PopLayoutDataV5 => {
|
||||
const componentsV4 = Object.values(layoutV4.components);
|
||||
const componentsV5: Record<string, PopComponentDefinitionV5> = {};
|
||||
|
||||
// Flexbox 순서 → Grid 위치 변환
|
||||
let currentRow = 1;
|
||||
let currentCol = 1;
|
||||
const columns = 12;
|
||||
|
||||
componentsV4.forEach((comp) => {
|
||||
// 픽셀 → 칸 변환 (대략적)
|
||||
const colSpan = comp.size.width === "fill"
|
||||
? columns
|
||||
: Math.max(1, Math.min(12, Math.round((comp.size.fixedWidth || 100) / 85)));
|
||||
const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48));
|
||||
|
||||
// 줄바꿈 체크
|
||||
if (currentCol + colSpan - 1 > columns) {
|
||||
currentRow += 1;
|
||||
currentCol = 1;
|
||||
}
|
||||
|
||||
componentsV5[comp.id] = {
|
||||
id: comp.id,
|
||||
type: comp.type,
|
||||
label: comp.label,
|
||||
position: {
|
||||
col: currentCol,
|
||||
row: currentRow,
|
||||
colSpan,
|
||||
rowSpan,
|
||||
},
|
||||
visibility: comp.visibility,
|
||||
dataBinding: comp.dataBinding,
|
||||
config: comp.config,
|
||||
};
|
||||
|
||||
currentCol += colSpan;
|
||||
});
|
||||
|
||||
return {
|
||||
version: "pop-5.0",
|
||||
gridConfig: {
|
||||
rowHeight: 48,
|
||||
gap: layoutV4.settings.defaultGap,
|
||||
padding: layoutV4.settings.defaultPadding,
|
||||
},
|
||||
components: componentsV5,
|
||||
dataFlow: layoutV4.dataFlow,
|
||||
settings: {
|
||||
touchTargetMin: layoutV4.settings.touchTargetMin,
|
||||
mode: layoutV4.settings.mode,
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5.2: 그리드 렌더러
|
||||
|
||||
### 작업 4: PopGridRenderer.tsx
|
||||
|
||||
**파일**: `frontend/components/pop/designer/renderers/PopGridRenderer.tsx`
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PopLayoutDataV5,
|
||||
PopComponentDefinitionV5,
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
GRID_BREAKPOINTS,
|
||||
detectGridMode,
|
||||
} from "../types/pop-layout";
|
||||
|
||||
interface PopGridRendererProps {
|
||||
layout: PopLayoutDataV5;
|
||||
viewportWidth: number;
|
||||
currentMode?: GridMode;
|
||||
isDesignMode?: boolean;
|
||||
selectedComponentId?: string | null;
|
||||
onComponentClick?: (componentId: string) => void;
|
||||
onBackgroundClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PopGridRenderer({
|
||||
layout,
|
||||
viewportWidth,
|
||||
currentMode,
|
||||
isDesignMode = false,
|
||||
selectedComponentId,
|
||||
onComponentClick,
|
||||
onBackgroundClick,
|
||||
className,
|
||||
}: PopGridRendererProps) {
|
||||
const { gridConfig, components, overrides } = layout;
|
||||
|
||||
// 현재 모드 (자동 감지 또는 지정)
|
||||
const mode = currentMode || detectGridMode(viewportWidth);
|
||||
const breakpoint = GRID_BREAKPOINTS[mode];
|
||||
|
||||
// CSS Grid 스타일
|
||||
const gridStyle = useMemo((): React.CSSProperties => ({
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
|
||||
gridAutoRows: `${breakpoint.rowHeight}px`,
|
||||
gap: `${breakpoint.gap}px`,
|
||||
padding: `${breakpoint.padding}px`,
|
||||
minHeight: "100%",
|
||||
}), [breakpoint]);
|
||||
|
||||
// visibility 체크
|
||||
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
|
||||
if (!comp.visibility) return true;
|
||||
return comp.visibility[mode] !== false;
|
||||
};
|
||||
|
||||
// 위치 변환 (12칸 기준 → 현재 모드 칸 수)
|
||||
const convertPosition = (position: PopGridPosition): React.CSSProperties => {
|
||||
const sourceColumns = 12; // 항상 12칸 기준으로 저장
|
||||
const targetColumns = breakpoint.columns;
|
||||
|
||||
if (sourceColumns === targetColumns) {
|
||||
return {
|
||||
gridColumn: `${position.col} / span ${position.colSpan}`,
|
||||
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 비율 계산
|
||||
const ratio = targetColumns / sourceColumns;
|
||||
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
|
||||
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
|
||||
|
||||
// 범위 초과 방지
|
||||
if (newCol + newColSpan - 1 > targetColumns) {
|
||||
newColSpan = targetColumns - newCol + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`,
|
||||
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||
};
|
||||
};
|
||||
|
||||
// 오버라이드 적용
|
||||
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
|
||||
const override = overrides?.[mode]?.positions?.[comp.id];
|
||||
if (override) {
|
||||
return { ...comp.position, ...override };
|
||||
}
|
||||
return comp.position;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative min-h-full w-full bg-white", className)}
|
||||
style={gridStyle}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onBackgroundClick?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Object.values(components).map((comp) => {
|
||||
if (!isVisible(comp)) return null;
|
||||
|
||||
const position = getEffectivePosition(comp);
|
||||
const positionStyle = convertPosition(position);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className={cn(
|
||||
"relative rounded-lg border-2 bg-white transition-all overflow-hidden",
|
||||
selectedComponentId === comp.id
|
||||
? "border-primary ring-2 ring-primary/30 z-10"
|
||||
: "border-gray-200",
|
||||
isDesignMode && "cursor-pointer hover:border-gray-300"
|
||||
)}
|
||||
style={positionStyle}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onComponentClick?.(comp.id);
|
||||
}}
|
||||
>
|
||||
{/* 컴포넌트 내용 */}
|
||||
<ComponentContent component={comp} isDesignMode={isDesignMode} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 컴포넌트 내용 렌더링
|
||||
function ComponentContent({
|
||||
component,
|
||||
isDesignMode
|
||||
}: {
|
||||
component: PopComponentDefinitionV5;
|
||||
isDesignMode: boolean;
|
||||
}) {
|
||||
const typeLabels: Record<string, string> = {
|
||||
"pop-field": "필드",
|
||||
"pop-button": "버튼",
|
||||
"pop-list": "리스트",
|
||||
"pop-indicator": "인디케이터",
|
||||
"pop-scanner": "스캐너",
|
||||
"pop-numpad": "숫자패드",
|
||||
"pop-spacer": "스페이서",
|
||||
"pop-break": "줄바꿈",
|
||||
};
|
||||
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex h-5 shrink-0 items-center border-b bg-gray-50 px-2">
|
||||
<span className="text-[10px] font-medium text-gray-600">
|
||||
{component.label || typeLabels[component.type] || component.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center p-2">
|
||||
<span className="text-xs text-gray-400">
|
||||
{typeLabels[component.type]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 실제 컴포넌트 렌더링 (Phase 4에서 구현)
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{component.label || typeLabels[component.type]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PopGridRenderer;
|
||||
```
|
||||
|
||||
### 작업 5: 위치 변환 유틸리티
|
||||
|
||||
**파일**: `frontend/components/pop/designer/utils/gridUtils.ts`
|
||||
|
||||
```typescript
|
||||
import { PopGridPosition, GridMode, GRID_BREAKPOINTS } from "../types/pop-layout";
|
||||
|
||||
/**
|
||||
* 12칸 기준 위치를 다른 모드로 변환
|
||||
*/
|
||||
export function convertPositionToMode(
|
||||
position: PopGridPosition,
|
||||
targetMode: GridMode
|
||||
): PopGridPosition {
|
||||
const sourceColumns = 12;
|
||||
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
|
||||
|
||||
if (sourceColumns === targetColumns) {
|
||||
return position;
|
||||
}
|
||||
|
||||
const ratio = targetColumns / sourceColumns;
|
||||
|
||||
// 열 위치 변환
|
||||
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
|
||||
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
|
||||
|
||||
// 범위 초과 방지
|
||||
if (newCol > targetColumns) {
|
||||
newCol = 1;
|
||||
}
|
||||
if (newCol + newColSpan - 1 > targetColumns) {
|
||||
newColSpan = targetColumns - newCol + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
col: newCol,
|
||||
row: position.row,
|
||||
colSpan: Math.max(1, newColSpan),
|
||||
rowSpan: position.rowSpan,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 위치가 겹치는지 확인
|
||||
*/
|
||||
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
|
||||
// 열 겹침
|
||||
const colOverlap = !(a.col + a.colSpan - 1 < b.col || b.col + b.colSpan - 1 < a.col);
|
||||
// 행 겹침
|
||||
const rowOverlap = !(a.row + a.rowSpan - 1 < b.row || b.row + b.rowSpan - 1 < a.row);
|
||||
|
||||
return colOverlap && rowOverlap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 겹침 해결 (아래로 밀기)
|
||||
*/
|
||||
export function resolveOverlaps(
|
||||
positions: Array<{ id: string; position: PopGridPosition }>,
|
||||
columns: number
|
||||
): Array<{ id: string; position: PopGridPosition }> {
|
||||
// row, col 순으로 정렬
|
||||
const sorted = [...positions].sort((a, b) =>
|
||||
a.position.row - b.position.row || a.position.col - b.position.col
|
||||
);
|
||||
|
||||
const resolved: Array<{ id: string; position: PopGridPosition }> = [];
|
||||
|
||||
sorted.forEach((item) => {
|
||||
let { row, col, colSpan, rowSpan } = item.position;
|
||||
|
||||
// 기존 배치와 겹치면 아래로 이동
|
||||
let attempts = 0;
|
||||
while (attempts < 100) {
|
||||
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
|
||||
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
|
||||
|
||||
if (!hasOverlap) break;
|
||||
|
||||
row++;
|
||||
attempts++;
|
||||
}
|
||||
|
||||
resolved.push({
|
||||
id: item.id,
|
||||
position: { col, row, colSpan, rowSpan },
|
||||
});
|
||||
});
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 마우스 좌표 → 그리드 좌표 변환
|
||||
*/
|
||||
export function mouseToGridPosition(
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
canvasRect: DOMRect,
|
||||
columns: number,
|
||||
rowHeight: number,
|
||||
gap: number,
|
||||
padding: number
|
||||
): { col: number; row: number } {
|
||||
// 캔버스 내 상대 위치
|
||||
const relX = mouseX - canvasRect.left - padding;
|
||||
const relY = mouseY - canvasRect.top - padding;
|
||||
|
||||
// 칸 너비 계산
|
||||
const totalGap = gap * (columns - 1);
|
||||
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
|
||||
|
||||
// 그리드 좌표 계산 (1부터 시작)
|
||||
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
|
||||
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
|
||||
|
||||
return { col, row };
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리드 좌표 → 픽셀 좌표 변환
|
||||
*/
|
||||
export function gridToPixelPosition(
|
||||
col: number,
|
||||
row: number,
|
||||
canvasWidth: number,
|
||||
columns: number,
|
||||
rowHeight: number,
|
||||
gap: number,
|
||||
padding: number
|
||||
): { x: number; y: number; width: number; height: number } {
|
||||
const totalGap = gap * (columns - 1);
|
||||
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
|
||||
|
||||
return {
|
||||
x: padding + (col - 1) * (colWidth + gap),
|
||||
y: padding + (row - 1) * (rowHeight + gap),
|
||||
width: colWidth,
|
||||
height: rowHeight,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5.3: 디자이너 UI
|
||||
|
||||
### 작업 6-7: PopCanvasV5.tsx
|
||||
|
||||
**파일**: `frontend/components/pop/designer/PopCanvasV5.tsx`
|
||||
|
||||
핵심 기능:
|
||||
- 그리드 배경 표시 (바둑판)
|
||||
- 4개 모드 프리셋 버튼
|
||||
- 드래그 앤 드롭 (칸에 스냅)
|
||||
- 컴포넌트 리사이즈 (칸 단위)
|
||||
|
||||
### 작업 8: ComponentEditorPanelV5.tsx
|
||||
|
||||
**파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx`
|
||||
|
||||
핵심 기능:
|
||||
- 위치 편집 (col, row 입력)
|
||||
- 크기 편집 (colSpan, rowSpan 입력)
|
||||
- visibility 체크박스
|
||||
|
||||
---
|
||||
|
||||
## Phase 5.4: 통합
|
||||
|
||||
### 작업 9: 자동 변환 알고리즘
|
||||
|
||||
이미 `gridUtils.ts`에 포함
|
||||
|
||||
### 작업 10: PopDesigner.tsx 통합
|
||||
|
||||
**수정 파일**: `frontend/components/pop/designer/PopDesigner.tsx`
|
||||
|
||||
변경 사항:
|
||||
- v5 레이아웃 상태 추가
|
||||
- v3/v4/v5 자동 판별
|
||||
- 새 화면 → v5로 시작
|
||||
- v4 → v5 마이그레이션 옵션
|
||||
|
||||
---
|
||||
|
||||
## 파일 목록
|
||||
|
||||
| 상태 | 파일 | 작업 |
|
||||
|------|------|------|
|
||||
| 수정 | `types/pop-layout.ts` | v5 타입, 상수, 함수 추가 |
|
||||
| 생성 | `renderers/PopGridRenderer.tsx` | 그리드 렌더러 |
|
||||
| 생성 | `utils/gridUtils.ts` | 유틸리티 함수 |
|
||||
| 생성 | `PopCanvasV5.tsx` | 그리드 캔버스 |
|
||||
| 생성 | `panels/ComponentEditorPanelV5.tsx` | 속성 패널 |
|
||||
| 수정 | `PopDesigner.tsx` | v5 통합 |
|
||||
|
||||
---
|
||||
|
||||
## 시작 순서
|
||||
|
||||
```
|
||||
1. pop-layout.ts에 v5 타입 추가 (작업 1-3)
|
||||
↓
|
||||
2. PopGridRenderer.tsx 생성 (작업 4)
|
||||
↓
|
||||
3. gridUtils.ts 생성 (작업 5)
|
||||
↓
|
||||
4. PopCanvasV5.tsx 생성 (작업 6-7)
|
||||
↓
|
||||
5. ComponentEditorPanelV5.tsx 생성 (작업 8)
|
||||
↓
|
||||
6. PopDesigner.tsx 수정 (작업 9-10)
|
||||
↓
|
||||
7. 테스트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*다음 단계: Phase 5.1 작업 1 시작 (v5 타입 정의)*
|
||||
|
|
@ -1,329 +0,0 @@
|
|||
# POP 화면 그리드 시스템 설계
|
||||
|
||||
> 작성일: 2026-02-05
|
||||
> 상태: 계획 (Plan)
|
||||
> 관련: Softr, Ant Design, Material Design 분석 기반
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적
|
||||
|
||||
POP 화면의 반응형 레이아웃을 **일관성 있고 예측 가능하게** 만들기 위한 그리드 시스템 설계
|
||||
|
||||
### 현재 문제
|
||||
- 픽셀 단위 자유 배치 → 화면 크기별로 깨짐
|
||||
- 컴포넌트 크기 규칙 없음 → 디자인 불일치
|
||||
- 반응형 규칙 부족 → 모드별 수동 조정 필요
|
||||
|
||||
### 목표
|
||||
- 그리드 기반 배치로 일관성 확보
|
||||
- 크기 프리셋으로 디자인 통일
|
||||
- 자동 반응형으로 작업량 감소
|
||||
|
||||
---
|
||||
|
||||
## 2. 대상 디바이스
|
||||
|
||||
### 지원 범위
|
||||
|
||||
| 구분 | 크기 범위 | 기준 해상도 | 비고 |
|
||||
|------|----------|-------------|------|
|
||||
| 모바일 | 4~8인치 | 375x667 (세로) | 산업용 PDA 포함 |
|
||||
| 태블릿 | 8~14인치 | 1024x768 (가로) | 기본 기준 |
|
||||
|
||||
### 참고: 산업용 디바이스 해상도
|
||||
|
||||
| 디바이스 | 화면 크기 | 해상도 |
|
||||
|----------|----------|--------|
|
||||
| Zebra TC57 PDA | 5인치 | 720x1280 |
|
||||
| Honeywell CT47 | 5.5인치 | 2160x1080 |
|
||||
| Honeywell RT10A | 10.1인치 | 1920x1200 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 그리드 시스템 설계
|
||||
|
||||
### 3.1 브레이크포인트 (Breakpoints)
|
||||
|
||||
Material Design 가이드라인 기반으로 4단계 정의:
|
||||
|
||||
| 모드 | 약어 | 너비 범위 | 대표 디바이스 | 그리드 칸 수 |
|
||||
|------|------|----------|---------------|-------------|
|
||||
| 모바일 세로 | `mp` | ~599px | 4~6인치 폰 | **4 columns** |
|
||||
| 모바일 가로 | `ml` | 600~839px | 폰 가로, 7인치 태블릿 | **6 columns** |
|
||||
| 태블릿 세로 | `tp` | 840~1023px | 8~10인치 태블릿 세로 | **8 columns** |
|
||||
| 태블릿 가로 | `tl` | 1024px~ | 10~14인치 태블릿 가로 | **12 columns** |
|
||||
|
||||
### 3.2 기준 해상도
|
||||
|
||||
| 모드 | 기준 너비 | 기준 높이 | 비고 |
|
||||
|------|----------|----------|------|
|
||||
| 모바일 세로 | 375px | 667px | iPhone SE 기준 |
|
||||
| 모바일 가로 | 667px | 375px | - |
|
||||
| 태블릿 세로 | 768px | 1024px | iPad 기준 |
|
||||
| **태블릿 가로** | **1024px** | **768px** | **기본 설계 모드** |
|
||||
|
||||
### 3.3 그리드 구조
|
||||
|
||||
```
|
||||
태블릿 가로 (12 columns)
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ ← 16px →│ Col │ 16px │ Col │ 16px │ ... │ Col │← 16px →│
|
||||
│ margin │ 1 │ gap │ 2 │ gap │ │ 12 │ margin │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
모바일 세로 (4 columns)
|
||||
┌────────────────────────┐
|
||||
│← 16px →│ Col │ 8px │ Col │ 8px │ Col │ 8px │ Col │← 16px →│
|
||||
│ margin │ 1 │ gap │ 2 │ gap │ 3 │ gap │ 4 │ margin │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.4 마진/간격 규칙
|
||||
|
||||
8px 기반 간격 시스템 (Material Design 표준):
|
||||
|
||||
| 속성 | 태블릿 | 모바일 | 용도 |
|
||||
|------|--------|--------|------|
|
||||
| screenPadding | 24px | 16px | 화면 가장자리 여백 |
|
||||
| gapSm | 8px | 8px | 컴포넌트 사이 최소 간격 |
|
||||
| gapMd | 16px | 12px | 기본 간격 |
|
||||
| gapLg | 24px | 16px | 섹션 간 간격 |
|
||||
| rowGap | 16px | 12px | 줄 사이 간격 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트 크기 시스템
|
||||
|
||||
### 4.1 열 단위 (Span) 크기
|
||||
|
||||
픽셀 대신 **열 단위(span)** 로 크기 지정:
|
||||
|
||||
| 크기 | 태블릿 가로 (12col) | 태블릿 세로 (8col) | 모바일 (4col) |
|
||||
|------|--------------------|--------------------|---------------|
|
||||
| XS | 1 span | 1 span | 1 span |
|
||||
| S | 2 span | 2 span | 2 span |
|
||||
| M | 3 span | 2 span | 2 span |
|
||||
| L | 4 span | 4 span | 4 span (full) |
|
||||
| XL | 6 span | 4 span | 4 span (full) |
|
||||
| Full | 12 span | 8 span | 4 span |
|
||||
|
||||
### 4.2 높이 프리셋
|
||||
|
||||
| 프리셋 | 픽셀값 | 용도 |
|
||||
|--------|--------|------|
|
||||
| `xs` | 32px | 배지, 아이콘 버튼 |
|
||||
| `sm` | 48px | 일반 버튼, 입력 필드 |
|
||||
| `md` | 80px | 카드, 인디케이터 |
|
||||
| `lg` | 120px | 큰 카드, 리스트 아이템 |
|
||||
| `xl` | 200px | 대형 영역 |
|
||||
| `auto` | 내용 기반 | 가변 높이 |
|
||||
|
||||
### 4.3 컴포넌트별 기본값
|
||||
|
||||
| 컴포넌트 | 태블릿 span | 모바일 span | 높이 | 비고 |
|
||||
|----------|------------|-------------|------|------|
|
||||
| pop-field | 3 (M) | 2 (S) | sm | 입력/표시 |
|
||||
| pop-button | 2 (S) | 2 (S) | sm | 액션 버튼 |
|
||||
| pop-list | 12 (Full) | 4 (Full) | auto | 데이터 목록 |
|
||||
| pop-indicator | 3 (M) | 2 (S) | md | KPI 표시 |
|
||||
| pop-scanner | 6 (XL) | 4 (Full) | lg | 스캔 영역 |
|
||||
| pop-numpad | 6 (XL) | 4 (Full) | auto | 숫자 패드 |
|
||||
| pop-spacer | 1 (XS) | 1 (XS) | - | 빈 공간 |
|
||||
| pop-break | Full | Full | 0 | 줄바꿈 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 반응형 규칙
|
||||
|
||||
### 5.1 자동 조정
|
||||
|
||||
설계자가 별도 설정하지 않아도 자동 적용:
|
||||
|
||||
```
|
||||
태블릿 가로 (12col): [A:3] [B:3] [C:3] [D:3] → 한 줄
|
||||
태블릿 세로 (8col): [A:2] [B:2] [C:2] [D:2] → 한 줄
|
||||
모바일 (4col): [A:2] [B:2] → 두 줄
|
||||
[C:2] [D:2]
|
||||
```
|
||||
|
||||
### 5.2 수동 오버라이드
|
||||
|
||||
필요시 모드별 설정 가능:
|
||||
|
||||
```typescript
|
||||
interface ResponsiveOverride {
|
||||
// 크기 변경
|
||||
span?: number;
|
||||
height?: HeightPreset;
|
||||
|
||||
// 표시/숨김
|
||||
hidden?: boolean;
|
||||
|
||||
// 내부 요소 숨김 (컴포넌트별)
|
||||
hideElements?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 표시/숨김 예시
|
||||
|
||||
```
|
||||
태블릿: [제품명] [수량] [단가] [합계] [비고]
|
||||
모바일: [제품명] [수량] [비고] ← 단가, 합계 숨김
|
||||
```
|
||||
|
||||
설정:
|
||||
```typescript
|
||||
{
|
||||
id: "unit-price",
|
||||
type: "pop-field",
|
||||
visibility: {
|
||||
mobile_portrait: false,
|
||||
mobile_landscape: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 데이터 구조 (제안)
|
||||
|
||||
### 6.1 레이아웃 데이터 (v5 제안)
|
||||
|
||||
```typescript
|
||||
interface PopLayoutDataV5 {
|
||||
version: "5.0";
|
||||
|
||||
// 그리드 설정 (전역)
|
||||
gridConfig: {
|
||||
tablet: { columns: 12; gap: 16; padding: 24 };
|
||||
mobile: { columns: 4; gap: 8; padding: 16 };
|
||||
};
|
||||
|
||||
// 컴포넌트 목록 (순서대로)
|
||||
components: PopComponentV5[];
|
||||
|
||||
// 모드별 오버라이드 (선택)
|
||||
modeOverrides?: {
|
||||
[mode: string]: {
|
||||
gridConfig?: Partial<GridConfig>;
|
||||
componentOverrides?: Record<string, ResponsiveOverride>;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 컴포넌트 데이터
|
||||
|
||||
```typescript
|
||||
interface PopComponentV5 {
|
||||
id: string;
|
||||
type: PopComponentType;
|
||||
|
||||
// 크기 (span 단위)
|
||||
size: {
|
||||
span: number; // 기본 열 개수 (1~12)
|
||||
height: HeightPreset; // xs, sm, md, lg, xl, auto
|
||||
};
|
||||
|
||||
// 반응형 크기 (선택)
|
||||
responsiveSize?: {
|
||||
mobile?: { span?: number; height?: HeightPreset };
|
||||
tablet_portrait?: { span?: number; height?: HeightPreset };
|
||||
};
|
||||
|
||||
// 표시/숨김
|
||||
visibility?: {
|
||||
[mode: string]: boolean;
|
||||
};
|
||||
|
||||
// 컴포넌트별 설정
|
||||
config?: any;
|
||||
|
||||
// 데이터 바인딩
|
||||
dataBinding?: any;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 현재 v4와의 관계
|
||||
|
||||
### 7.1 v4 유지 사항
|
||||
- Flexbox 기반 렌더링
|
||||
- 오버라이드 시스템
|
||||
- visibility 속성
|
||||
|
||||
### 7.2 변경 사항
|
||||
|
||||
| v4 | v5 (제안) |
|
||||
|----|-----------|
|
||||
| `fixedWidth: number` | `span: 1~12` |
|
||||
| `minWidth`, `maxWidth` | 그리드 기반 자동 계산 |
|
||||
| 자유 픽셀 | 열 단위 프리셋 |
|
||||
|
||||
### 7.3 마이그레이션 방향
|
||||
|
||||
```
|
||||
v4 fixedWidth: 200px
|
||||
↓
|
||||
v5 span: 3 (태블릿 기준 약 25%)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 구현 우선순위
|
||||
|
||||
### Phase 1: 프리셋만 적용 (최소 변경)
|
||||
- [ ] 높이 프리셋 드롭다운
|
||||
- [ ] 너비 프리셋 드롭다운 (XS~Full)
|
||||
- [ ] 기존 Flexbox 렌더링 유지
|
||||
|
||||
### Phase 2: 그리드 시스템 도입
|
||||
- [ ] 브레이크포인트 감지
|
||||
- [ ] 그리드 칸 수 자동 변경
|
||||
- [ ] span → 픽셀 자동 계산
|
||||
|
||||
### Phase 3: 반응형 자동화
|
||||
- [ ] 모드별 자동 span 변환
|
||||
- [ ] 줄바꿈 자동 처리
|
||||
- [ ] 오버라이드 최소화
|
||||
|
||||
---
|
||||
|
||||
## 9. 참고 자료
|
||||
|
||||
### 분석 대상
|
||||
|
||||
| 도구 | 핵심 특징 | 적용 가능 요소 |
|
||||
|------|----------|---------------|
|
||||
| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 |
|
||||
| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 |
|
||||
| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 |
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
1. **Flexbox는 도구**: 그리드 시스템 안에서 사용
|
||||
2. **제약은 자유**: 규칙이 있어야 일관된 디자인 가능
|
||||
3. **최소 설정, 최대 효과**: 기본값이 좋으면 오버라이드 불필요
|
||||
|
||||
---
|
||||
|
||||
## 10. FAQ
|
||||
|
||||
### Q1: 기존 v4 화면은 어떻게 되나요?
|
||||
A: 하위 호환 유지. v4 화면은 v4로 계속 동작.
|
||||
|
||||
### Q2: 컴포넌트를 그리드 칸 사이에 배치할 수 있나요?
|
||||
A: 아니요. 칸 단위로만 배치. 이게 일관성의 핵심.
|
||||
|
||||
### Q3: 그리드 칸 수를 바꿀 수 있나요?
|
||||
A: 기본값(4/6/8/12) 권장. 필요시 프로젝트 레벨 설정 가능.
|
||||
|
||||
### Q4: Flexbox와 Grid 중 뭘 쓰나요?
|
||||
A: 둘 다. Grid로 칸 나누고, Flexbox로 칸 안에서 정렬.
|
||||
|
||||
---
|
||||
|
||||
*이 문서는 계획 단계이며, 실제 구현 시 수정될 수 있습니다.*
|
||||
*최종 업데이트: 2026-02-05*
|
||||
|
|
@ -1,480 +0,0 @@
|
|||
# POP 그리드 시스템 도입 계획
|
||||
|
||||
> 작성일: 2026-02-05
|
||||
> 상태: 계획 승인, 구현 대기
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 목표
|
||||
현재 Flexbox 기반 v4 시스템을 **CSS Grid 기반 v5 시스템**으로 전환하여
|
||||
4~14인치 화면에서 일관된 배치와 예측 가능한 반응형 레이아웃 구현
|
||||
|
||||
### 핵심 변경점
|
||||
|
||||
| 항목 | v4 (현재) | v5 (그리드) |
|
||||
|------|----------|-------------|
|
||||
| 배치 방식 | Flexbox 흐름 | **Grid 위치 지정** |
|
||||
| 크기 단위 | 픽셀 (200px) | **칸 (col, row)** |
|
||||
| 위치 지정 | 순서대로 자동 | **열/행 좌표** |
|
||||
| 줄바꿈 | 자동 (넘치면) | **명시적 (row 지정)** |
|
||||
|
||||
---
|
||||
|
||||
## Phase 구조
|
||||
|
||||
```
|
||||
[Phase 5.1] [Phase 5.2] [Phase 5.3] [Phase 5.4]
|
||||
그리드 타입 정의 → 그리드 렌더러 → 디자이너 UI → 반응형 자동화
|
||||
1주 1주 1~2주 1주
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5.1: 그리드 타입 정의
|
||||
|
||||
### 목표
|
||||
v5 레이아웃 데이터 구조 설계
|
||||
|
||||
### 작업 항목
|
||||
|
||||
- [ ] `PopLayoutDataV5` 인터페이스 정의
|
||||
- [ ] `PopGridConfig` 인터페이스 (그리드 설정)
|
||||
- [ ] `PopComponentPositionV5` 인터페이스 (위치: col, row, colSpan, rowSpan)
|
||||
- [ ] `PopSizeConstraintV5` 인터페이스 (칸 기반 크기)
|
||||
- [ ] 브레이크포인트 상수 정의
|
||||
- [ ] `createEmptyPopLayoutV5()` 생성 함수
|
||||
- [ ] `isV5Layout()` 타입 가드
|
||||
|
||||
### 데이터 구조 설계
|
||||
|
||||
```typescript
|
||||
// v5 레이아웃
|
||||
interface PopLayoutDataV5 {
|
||||
version: "pop-5.0";
|
||||
|
||||
// 그리드 설정
|
||||
gridConfig: PopGridConfig;
|
||||
|
||||
// 컴포넌트 목록
|
||||
components: Record<string, PopComponentDefinitionV5>;
|
||||
|
||||
// 모드별 오버라이드
|
||||
overrides?: {
|
||||
mobile_portrait?: PopModeOverrideV5;
|
||||
mobile_landscape?: PopModeOverrideV5;
|
||||
tablet_portrait?: PopModeOverrideV5;
|
||||
};
|
||||
|
||||
// 기존 호환
|
||||
dataFlow: PopDataFlow;
|
||||
settings: PopGlobalSettingsV5;
|
||||
}
|
||||
|
||||
// 그리드 설정
|
||||
interface PopGridConfig {
|
||||
// 모드별 칸 수
|
||||
columns: {
|
||||
tablet_landscape: 12; // 기본 (10~14인치)
|
||||
tablet_portrait: 8; // 8~10인치 세로
|
||||
mobile_landscape: 6; // 6~8인치 가로
|
||||
mobile_portrait: 4; // 4~6인치 세로
|
||||
};
|
||||
|
||||
// 행 높이 (px) - 1행의 기본 높이
|
||||
rowHeight: number; // 기본 48px
|
||||
|
||||
// 간격
|
||||
gap: number; // 기본 8px
|
||||
padding: number; // 기본 16px
|
||||
}
|
||||
|
||||
// 컴포넌트 정의
|
||||
interface PopComponentDefinitionV5 {
|
||||
id: string;
|
||||
type: PopComponentType;
|
||||
label?: string;
|
||||
|
||||
// 위치 (열/행 좌표) - 기본 모드(태블릿 가로) 기준
|
||||
position: {
|
||||
col: number; // 시작 열 (1부터)
|
||||
row: number; // 시작 행 (1부터)
|
||||
colSpan: number; // 차지할 열 수 (1~12)
|
||||
rowSpan: number; // 차지할 행 수 (1~)
|
||||
};
|
||||
|
||||
// 모드별 표시/숨김
|
||||
visibility?: {
|
||||
tablet_landscape?: boolean;
|
||||
tablet_portrait?: boolean;
|
||||
mobile_landscape?: boolean;
|
||||
mobile_portrait?: boolean;
|
||||
};
|
||||
|
||||
// 기존 속성
|
||||
dataBinding?: PopDataBinding;
|
||||
config?: PopComponentConfig;
|
||||
}
|
||||
```
|
||||
|
||||
### 브레이크포인트 정의
|
||||
|
||||
```typescript
|
||||
// 브레이크포인트 상수
|
||||
const GRID_BREAKPOINTS = {
|
||||
// 4~6인치 모바일 세로
|
||||
mobile_portrait: {
|
||||
maxWidth: 599,
|
||||
columns: 4,
|
||||
rowHeight: 40,
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
},
|
||||
|
||||
// 6~8인치 모바일 가로 / 작은 태블릿
|
||||
mobile_landscape: {
|
||||
minWidth: 600,
|
||||
maxWidth: 839,
|
||||
columns: 6,
|
||||
rowHeight: 44,
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
},
|
||||
|
||||
// 8~10인치 태블릿 세로
|
||||
tablet_portrait: {
|
||||
minWidth: 840,
|
||||
maxWidth: 1023,
|
||||
columns: 8,
|
||||
rowHeight: 48,
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
},
|
||||
|
||||
// 10~14인치 태블릿 가로 (기본)
|
||||
tablet_landscape: {
|
||||
minWidth: 1024,
|
||||
columns: 12,
|
||||
rowHeight: 48,
|
||||
gap: 16,
|
||||
padding: 24,
|
||||
},
|
||||
} as const;
|
||||
```
|
||||
|
||||
### 산출물
|
||||
- `frontend/components/pop/designer/types/pop-layout-v5.ts`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5.2: 그리드 렌더러
|
||||
|
||||
### 목표
|
||||
CSS Grid 기반 렌더러 구현
|
||||
|
||||
### 작업 항목
|
||||
|
||||
- [ ] `PopGridRenderer.tsx` 생성
|
||||
- [ ] CSS Grid 스타일 계산 로직
|
||||
- [ ] 브레이크포인트 감지 및 칸 수 자동 변경
|
||||
- [ ] 컴포넌트 위치 렌더링 (grid-column, grid-row)
|
||||
- [ ] 모드별 자동 위치 재계산 (12칸→4칸 변환)
|
||||
- [ ] visibility 처리
|
||||
- [ ] 기존 PopFlexRenderer와 공존
|
||||
|
||||
### 렌더링 로직
|
||||
|
||||
```typescript
|
||||
// CSS Grid 스타일 생성
|
||||
function calculateGridStyle(config: PopGridConfig, mode: string): React.CSSProperties {
|
||||
const columns = config.columns[mode];
|
||||
const { rowHeight, gap, padding } = config;
|
||||
|
||||
return {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gridAutoRows: `${rowHeight}px`,
|
||||
gap: `${gap}px`,
|
||||
padding: `${padding}px`,
|
||||
};
|
||||
}
|
||||
|
||||
// 컴포넌트 위치 스타일
|
||||
function calculatePositionStyle(
|
||||
position: PopComponentPositionV5['position'],
|
||||
sourceColumns: number, // 원본 모드 칸 수 (12)
|
||||
targetColumns: number // 현재 모드 칸 수 (4)
|
||||
): React.CSSProperties {
|
||||
// 12칸 → 4칸 변환 예시
|
||||
// col: 7, colSpan: 3 → col: 3, colSpan: 1
|
||||
const ratio = targetColumns / sourceColumns;
|
||||
const newCol = Math.max(1, Math.ceil(position.col * ratio));
|
||||
const newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
|
||||
|
||||
return {
|
||||
gridColumn: `${newCol} / span ${Math.min(newColSpan, targetColumns - newCol + 1)}`,
|
||||
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 산출물
|
||||
- `frontend/components/pop/designer/renderers/PopGridRenderer.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5.3: 디자이너 UI
|
||||
|
||||
### 목표
|
||||
그리드 기반 편집 UI 구현
|
||||
|
||||
### 작업 항목
|
||||
|
||||
- [ ] `PopCanvasV5.tsx` 생성 (그리드 캔버스)
|
||||
- [ ] 그리드 배경 표시 (바둑판 모양)
|
||||
- [ ] 컴포넌트 드래그 배치 (칸에 스냅)
|
||||
- [ ] 컴포넌트 리사이즈 (칸 단위)
|
||||
- [ ] 위치 편집 패널 (col, row, colSpan, rowSpan)
|
||||
- [ ] 모드 전환 시 그리드 칸 수 변경 표시
|
||||
- [ ] v4/v5 자동 판별 및 전환
|
||||
|
||||
### UI 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ← 목록 화면명 *변경됨 [↶][↷] 그리드 레이아웃 (v5) [저장] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 미리보기: [모바일↕ 4칸] [모바일↔ 6칸] [태블릿↕ 8칸] [태블릿↔ 12칸] │
|
||||
├────────────┬────────────────────────────────────┬───────────────┤
|
||||
│ │ 1 2 3 4 5 6 ... 12 │ │
|
||||
│ 컴포넌트 │ ┌───────────┬───────────┐ │ 위치 │
|
||||
│ │1│ A │ B │ │ 열: [1-12] │
|
||||
│ 필드 │ ├───────────┴───────────┤ │ 행: [1-99] │
|
||||
│ 버튼 │2│ C │ │ 너비: [1-12]│
|
||||
│ 리스트 │ ├───────────┬───────────┤ │ 높이: [1-10]│
|
||||
│ 인디케이터 │3│ D │ E │ │ │
|
||||
│ ... │ └───────────┴───────────┘ │ 표시 설정 │
|
||||
│ │ │ [x] 태블릿↔ │
|
||||
│ │ (그리드 배경 표시) │ [x] 모바일↕ │
|
||||
└────────────┴────────────────────────────────────┴───────────────┘
|
||||
```
|
||||
|
||||
### 드래그 앤 드롭 로직
|
||||
|
||||
```typescript
|
||||
// 마우스 위치 → 그리드 좌표 변환
|
||||
function mouseToGridPosition(
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
gridConfig: PopGridConfig,
|
||||
canvasRect: DOMRect
|
||||
): { col: number; row: number } {
|
||||
const { columns, rowHeight, gap, padding } = gridConfig;
|
||||
|
||||
// 캔버스 내 상대 위치
|
||||
const relX = mouseX - canvasRect.left - padding;
|
||||
const relY = mouseY - canvasRect.top - padding;
|
||||
|
||||
// 칸 너비 계산
|
||||
const totalGap = gap * (columns - 1);
|
||||
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
|
||||
|
||||
// 그리드 좌표 계산
|
||||
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
|
||||
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
|
||||
|
||||
return { col, row };
|
||||
}
|
||||
```
|
||||
|
||||
### 산출물
|
||||
- `frontend/components/pop/designer/PopCanvasV5.tsx`
|
||||
- `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5.4: 반응형 자동화
|
||||
|
||||
### 목표
|
||||
모드 전환 시 자동 레이아웃 조정
|
||||
|
||||
### 작업 항목
|
||||
|
||||
- [ ] 12칸 → 4칸 자동 변환 알고리즘
|
||||
- [ ] 겹침 감지 및 자동 재배치
|
||||
- [ ] 모드별 오버라이드 저장
|
||||
- [ ] "자동 배치" vs "수동 고정" 선택
|
||||
- [ ] 변환 미리보기
|
||||
|
||||
### 자동 변환 알고리즘
|
||||
|
||||
```typescript
|
||||
// 12칸 → 4칸 변환 전략
|
||||
function convertLayoutToMode(
|
||||
components: PopComponentDefinitionV5[],
|
||||
sourceMode: 'tablet_landscape', // 12칸
|
||||
targetMode: 'mobile_portrait' // 4칸
|
||||
): PopComponentDefinitionV5[] {
|
||||
const sourceColumns = 12;
|
||||
const targetColumns = 4;
|
||||
const ratio = targetColumns / sourceColumns; // 0.333
|
||||
|
||||
// 1. 각 컴포넌트 위치 변환
|
||||
const converted = components.map(comp => {
|
||||
const newCol = Math.max(1, Math.ceil(comp.position.col * ratio));
|
||||
const newColSpan = Math.max(1, Math.round(comp.position.colSpan * ratio));
|
||||
|
||||
return {
|
||||
...comp,
|
||||
position: {
|
||||
...comp.position,
|
||||
col: newCol,
|
||||
colSpan: Math.min(newColSpan, targetColumns),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 2. 겹침 감지 및 해결
|
||||
return resolveOverlaps(converted, targetColumns);
|
||||
}
|
||||
|
||||
// 겹침 해결 (아래로 밀기)
|
||||
function resolveOverlaps(
|
||||
components: PopComponentDefinitionV5[],
|
||||
columns: number
|
||||
): PopComponentDefinitionV5[] {
|
||||
// 행 단위로 그리드 점유 상태 추적
|
||||
const grid: boolean[][] = [];
|
||||
|
||||
// row 순서대로 처리
|
||||
const sorted = [...components].sort((a, b) =>
|
||||
a.position.row - b.position.row || a.position.col - b.position.col
|
||||
);
|
||||
|
||||
return sorted.map(comp => {
|
||||
let { row, col, colSpan, rowSpan } = comp.position;
|
||||
|
||||
// 배치 가능한 위치 찾기
|
||||
while (isOccupied(grid, row, col, colSpan, rowSpan, columns)) {
|
||||
row++; // 아래로 이동
|
||||
}
|
||||
|
||||
// 그리드에 표시
|
||||
markOccupied(grid, row, col, colSpan, rowSpan);
|
||||
|
||||
return {
|
||||
...comp,
|
||||
position: { row, col, colSpan, rowSpan },
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 산출물
|
||||
- `frontend/components/pop/designer/utils/gridLayoutUtils.ts`
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 전략
|
||||
|
||||
### v4 → v5 변환
|
||||
|
||||
```typescript
|
||||
function migrateV4ToV5(layoutV4: PopLayoutDataV4): PopLayoutDataV5 {
|
||||
const componentsList = Object.values(layoutV4.components);
|
||||
|
||||
// Flexbox 순서 → Grid 위치 변환
|
||||
let currentRow = 1;
|
||||
let currentCol = 1;
|
||||
const columns = 12;
|
||||
|
||||
const componentsV5: Record<string, PopComponentDefinitionV5> = {};
|
||||
|
||||
componentsList.forEach((comp, index) => {
|
||||
// 기본 크기 추정 (픽셀 → 칸)
|
||||
const colSpan = Math.max(1, Math.round((comp.size.fixedWidth || 100) / 85));
|
||||
const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48));
|
||||
|
||||
// 줄바꿈 체크
|
||||
if (currentCol + colSpan - 1 > columns) {
|
||||
currentRow++;
|
||||
currentCol = 1;
|
||||
}
|
||||
|
||||
componentsV5[comp.id] = {
|
||||
...comp,
|
||||
position: {
|
||||
col: currentCol,
|
||||
row: currentRow,
|
||||
colSpan,
|
||||
rowSpan,
|
||||
},
|
||||
};
|
||||
|
||||
currentCol += colSpan;
|
||||
});
|
||||
|
||||
return {
|
||||
version: "pop-5.0",
|
||||
gridConfig: { /* 기본값 */ },
|
||||
components: componentsV5,
|
||||
dataFlow: layoutV4.dataFlow,
|
||||
settings: { /* 변환 */ },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 하위 호환
|
||||
|
||||
| 버전 | 처리 방식 |
|
||||
|------|----------|
|
||||
| v1~v2 | v3로 변환 후 v5로 |
|
||||
| v3 | v5로 직접 변환 |
|
||||
| v4 | v5로 직접 변환 |
|
||||
| v5 | 그대로 사용 |
|
||||
|
||||
---
|
||||
|
||||
## 일정 (예상)
|
||||
|
||||
| Phase | 작업 | 예상 기간 |
|
||||
|-------|------|----------|
|
||||
| 5.1 | 타입 정의 | 2~3일 |
|
||||
| 5.2 | 그리드 렌더러 | 3~5일 |
|
||||
| 5.3 | 디자이너 UI | 5~7일 |
|
||||
| 5.4 | 반응형 자동화 | 3~5일 |
|
||||
| - | 테스트 및 버그 수정 | 2~3일 |
|
||||
| **총** | | **약 2~3주** |
|
||||
|
||||
---
|
||||
|
||||
## 리스크 및 대응
|
||||
|
||||
| 리스크 | 영향 | 대응 |
|
||||
|--------|------|------|
|
||||
| 기존 v4 화면 깨짐 | 높음 | 하위 호환 유지, v4 렌더러 보존 |
|
||||
| 자동 변환 품질 | 중간 | 수동 오버라이드로 보완 |
|
||||
| 드래그 UX 복잡 | 중간 | 스냅 기능으로 편의성 확보 |
|
||||
| 성능 저하 | 낮음 | CSS Grid는 네이티브 성능 |
|
||||
|
||||
---
|
||||
|
||||
## 성공 기준
|
||||
|
||||
1. **배치 예측 가능**: "2열 3행"이라고 하면 정확히 그 위치에 표시
|
||||
2. **일관된 디자인**: 12칸 → 4칸 전환 시 비율 유지
|
||||
3. **쉬운 편집**: 드래그로 칸에 스냅되어 배치
|
||||
4. **하위 호환**: 기존 v4 화면이 정상 동작
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [GRID_SYSTEM_DESIGN.md](./GRID_SYSTEM_DESIGN.md) - 그리드 시스템 설계 상세
|
||||
- [PLAN.md](./PLAN.md) - 전체 POP 개발 계획
|
||||
- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - 현재 v4 스펙
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-02-05*
|
||||
|
|
@ -1,518 +0,0 @@
|
|||
# Phase 3 완료 요약
|
||||
|
||||
**날짜**: 2026-02-04
|
||||
**상태**: 완료 ✅
|
||||
**버전**: v4.0 Phase 3
|
||||
|
||||
---
|
||||
|
||||
## 🎯 달성 목표
|
||||
|
||||
Phase 2의 배치 고정 기능 이후, 다음 3가지 핵심 기능 추가:
|
||||
|
||||
1. ✅ **모드별 컴포넌트 표시/숨김** (visibility)
|
||||
2. ✅ **강제 줄바꿈 컴포넌트** (pop-break)
|
||||
3. ✅ **컴포넌트 오버라이드 병합** (모드별 설정 변경)
|
||||
|
||||
---
|
||||
|
||||
## 📦 구현 내용
|
||||
|
||||
### 1. 타입 정의
|
||||
|
||||
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||
|
||||
```typescript
|
||||
// pop-break 추가
|
||||
export type PopComponentType =
|
||||
| "pop-field"
|
||||
| "pop-button"
|
||||
| "pop-list"
|
||||
| "pop-indicator"
|
||||
| "pop-scanner"
|
||||
| "pop-numpad"
|
||||
| "pop-spacer"
|
||||
| "pop-break"; // 🆕
|
||||
|
||||
// visibility 속성 추가
|
||||
export interface PopComponentDefinitionV4 {
|
||||
id: string;
|
||||
type: PopComponentType;
|
||||
size: PopSizeConstraintV4;
|
||||
|
||||
visibility?: {
|
||||
tablet_landscape?: boolean;
|
||||
tablet_portrait?: boolean;
|
||||
mobile_landscape?: boolean;
|
||||
mobile_portrait?: boolean;
|
||||
};
|
||||
|
||||
// ...
|
||||
}
|
||||
|
||||
// 기본 크기
|
||||
defaultSizes["pop-break"] = {
|
||||
width: "fill", // 100% 너비
|
||||
height: "fixed",
|
||||
fixedHeight: 0, // 높이 0
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 렌더러 로직
|
||||
|
||||
**파일**: `frontend/components/pop/designer/renderers/PopFlexRenderer.tsx`
|
||||
|
||||
#### visibility 체크
|
||||
```typescript
|
||||
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
|
||||
if (!component.visibility) return true; // 기본값: 표시
|
||||
const modeVisibility = component.visibility[currentMode];
|
||||
return modeVisibility !== false; // undefined도 true로 취급
|
||||
};
|
||||
```
|
||||
|
||||
#### 컴포넌트 오버라이드 병합
|
||||
```typescript
|
||||
const getMergedComponent = (baseComponent: PopComponentDefinitionV4) => {
|
||||
if (currentMode === "tablet_landscape") return baseComponent;
|
||||
|
||||
const override = overrides?.[currentMode]?.components?.[baseComponent.id];
|
||||
if (!override) return baseComponent;
|
||||
|
||||
// 깊은 병합 (config, size)
|
||||
return {
|
||||
...baseComponent,
|
||||
...override,
|
||||
size: { ...baseComponent.size, ...override.size },
|
||||
config: { ...baseComponent.config, ...override.config },
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
#### pop-break 렌더링
|
||||
```typescript
|
||||
if (mergedComponent.type === "pop-break") {
|
||||
return (
|
||||
<div
|
||||
style={{ flexBasis: "100%" }} // 핵심: 100% 너비로 줄바꿈 강제
|
||||
className={isDesignMode
|
||||
? "h-4 border-2 border-dashed border-gray-300"
|
||||
: "h-0"
|
||||
}
|
||||
>
|
||||
{isDesignMode && <span>줄바꿈</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 삭제 함수 개선
|
||||
|
||||
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||
|
||||
```typescript
|
||||
export const removeComponentFromV4Layout = (
|
||||
layout: PopLayoutDataV4,
|
||||
componentId: string
|
||||
): PopLayoutDataV4 => {
|
||||
// 1. components에서 삭제
|
||||
const { [componentId]: _, ...remainingComponents } = layout.components;
|
||||
|
||||
// 2. root.children에서 제거
|
||||
const newRoot = removeChildFromContainer(layout.root, componentId);
|
||||
|
||||
// 3. 🆕 모든 오버라이드에서 제거
|
||||
const newOverrides = cleanupOverridesAfterDelete(layout.overrides, componentId);
|
||||
|
||||
return {
|
||||
...layout,
|
||||
root: newRoot,
|
||||
components: remainingComponents,
|
||||
overrides: newOverrides,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
#### 오버라이드 정리 로직
|
||||
```typescript
|
||||
function cleanupOverridesAfterDelete(
|
||||
overrides: PopLayoutDataV4["overrides"],
|
||||
componentId: string
|
||||
) {
|
||||
// 각 모드별로:
|
||||
// 1. containers.root.children에서 componentId 제거
|
||||
// 2. components[componentId] 제거
|
||||
// 3. 빈 오버라이드 자동 삭제
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 속성 패널 UI
|
||||
|
||||
**파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx`
|
||||
|
||||
#### "표시" 탭 추가
|
||||
```typescript
|
||||
<TabsList>
|
||||
<TabsTrigger value="size">크기</TabsTrigger>
|
||||
<TabsTrigger value="settings">설정</TabsTrigger>
|
||||
<TabsTrigger value="visibility">
|
||||
<Eye className="h-3 w-3" />
|
||||
표시
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="data">데이터</TabsTrigger>
|
||||
</TabsList>
|
||||
```
|
||||
|
||||
#### VisibilityForm 컴포넌트
|
||||
```typescript
|
||||
function VisibilityForm({ component, onUpdate }) {
|
||||
const modes = [
|
||||
{ key: "tablet_landscape", label: "태블릿 가로" },
|
||||
{ key: "tablet_portrait", label: "태블릿 세로" },
|
||||
{ key: "mobile_landscape", label: "모바일 가로" },
|
||||
{ key: "mobile_portrait", label: "모바일 세로" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{modes.map(({ key, label }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={component.visibility?.[key] !== false}
|
||||
onChange={(e) => {
|
||||
onUpdate?.({
|
||||
visibility: {
|
||||
...component.visibility,
|
||||
[key]: e.target.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 팔레트 업데이트
|
||||
|
||||
**파일**: `frontend/components/pop/designer/panels/ComponentPaletteV4.tsx`
|
||||
|
||||
```typescript
|
||||
const COMPONENT_PALETTE = [
|
||||
// ... 기존 컴포넌트들
|
||||
{
|
||||
type: "pop-break",
|
||||
label: "줄바꿈",
|
||||
icon: WrapText,
|
||||
description: "강제 줄바꿈 (flex-basis: 100%)",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI 변경사항
|
||||
|
||||
### 컴포넌트 팔레트
|
||||
```
|
||||
컴포넌트
|
||||
├─ 필드
|
||||
├─ 버튼
|
||||
├─ 리스트
|
||||
├─ 인디케이터
|
||||
├─ 스캐너
|
||||
├─ 숫자패드
|
||||
├─ 스페이서
|
||||
└─ 줄바꿈 🆕
|
||||
```
|
||||
|
||||
### 속성 패널
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ 탭: [크기][설정] │
|
||||
│ [표시📍][데이터] │
|
||||
├─────────────────────┤
|
||||
│ 모드별 표시 설정 │
|
||||
│ ☑ 태블릿 가로 │
|
||||
│ ☑ 태블릿 세로 │
|
||||
│ ☐ 모바일 가로 (숨김)│
|
||||
│ ☑ 모바일 세로 │
|
||||
├─────────────────────┤
|
||||
│ 반응형 숨김 │
|
||||
│ [500] px 이하 숨김 │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 사용 예시
|
||||
|
||||
### 예시 1: 모바일 전용 버튼
|
||||
```typescript
|
||||
{
|
||||
id: "call-button",
|
||||
type: "pop-button",
|
||||
label: "전화 걸기",
|
||||
visibility: {
|
||||
tablet_landscape: false, // 태블릿: 숨김
|
||||
tablet_portrait: false,
|
||||
mobile_landscape: true, // 모바일: 표시
|
||||
mobile_portrait: true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**결과**:
|
||||
- 태블릿 화면: "전화 걸기" 버튼 안 보임
|
||||
- 모바일 화면: "전화 걸기" 버튼 보임
|
||||
|
||||
---
|
||||
|
||||
### 예시 2: 모드별 줄바꿈
|
||||
```typescript
|
||||
레이아웃: [A] [B] [줄바꿈] [C] [D]
|
||||
|
||||
줄바꿈 설정:
|
||||
{
|
||||
id: "break-1",
|
||||
type: "pop-break",
|
||||
visibility: {
|
||||
tablet_landscape: false, // 태블릿: 줄바꿈 숨김
|
||||
mobile_portrait: true, // 모바일: 줄바꿈 표시
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결과**:
|
||||
```
|
||||
태블릿 가로 (1024px):
|
||||
┌───────────────────────────┐
|
||||
│ [A] [B] [C] [D] │ ← 한 줄
|
||||
└───────────────────────────┘
|
||||
|
||||
모바일 세로 (375px):
|
||||
┌─────────────────┐
|
||||
│ [A] [B] │ ← 첫 줄
|
||||
│ [C] [D] │ ← 둘째 줄 (줄바꿈 적용)
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 예시 3: 리스트 컬럼 수 변경 (확장 가능)
|
||||
```typescript
|
||||
// 기본 (태블릿 가로)
|
||||
{
|
||||
id: "product-list",
|
||||
type: "pop-list",
|
||||
config: {
|
||||
columns: 7, // 7개 컬럼
|
||||
}
|
||||
}
|
||||
|
||||
// 오버라이드 (모바일 세로)
|
||||
overrides: {
|
||||
mobile_portrait: {
|
||||
components: {
|
||||
"product-list": {
|
||||
config: {
|
||||
columns: 3, // 3개 컬럼
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결과**:
|
||||
- 태블릿: 7개 컬럼 표시
|
||||
- 모바일: 3개 컬럼 표시 (자동 병합)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 테스트 시나리오
|
||||
|
||||
### ✅ 테스트 1: 줄바꿈 기본 동작
|
||||
1. 팔레트에서 "줄바꿈" 드래그
|
||||
2. 컴포넌트 사이에 드롭
|
||||
3. 디자인 모드에서 점선 "줄바꿈" 표시 확인
|
||||
4. 미리보기에서 줄바꿈이 안 보이는지 확인
|
||||
|
||||
### ✅ 테스트 2: 모드별 줄바꿈
|
||||
1. 줄바꿈 컴포넌트 추가
|
||||
2. "표시" 탭 → 태블릿 모드 체크 해제
|
||||
3. 태블릿 가로: 한 줄
|
||||
4. 모바일 세로: 두 줄
|
||||
|
||||
### ✅ 테스트 3: 삭제 시 오버라이드 정리
|
||||
1. 모바일 세로에서 배치 고정
|
||||
2. 컴포넌트 삭제
|
||||
3. 저장 후 로드
|
||||
4. DB 확인: overrides에서도 제거되었는지
|
||||
|
||||
### ✅ 테스트 4: 컴포넌트 숨김
|
||||
1. 컴포넌트 선택
|
||||
2. "표시" 탭 → 태블릿 모드 체크 해제
|
||||
3. 태블릿: 컴포넌트 안 보임
|
||||
4. 모바일: 컴포넌트 보임
|
||||
|
||||
### ✅ 테스트 5: 속성 패널 UI
|
||||
1. 컴포넌트 선택
|
||||
2. "표시" 탭 클릭
|
||||
3. 4개 체크박스 확인
|
||||
4. 체크 해제 시 "(숨김)" 표시
|
||||
5. 저장 후 로드 → 상태 유지
|
||||
|
||||
---
|
||||
|
||||
## 📝 수정된 파일
|
||||
|
||||
### 코드 파일 (5개)
|
||||
```
|
||||
✅ frontend/components/pop/designer/types/pop-layout.ts
|
||||
- PopComponentType 확장 (pop-break)
|
||||
- PopComponentDefinitionV4.visibility 추가
|
||||
- cleanupOverridesAfterDelete() 추가
|
||||
|
||||
✅ frontend/components/pop/designer/renderers/PopFlexRenderer.tsx
|
||||
- isComponentVisible() 추가
|
||||
- getMergedComponent() 추가
|
||||
- pop-break 렌더링 추가
|
||||
- ContainerRenderer props 확장
|
||||
|
||||
✅ frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx
|
||||
- "표시" 탭 추가
|
||||
- VisibilityForm 컴포넌트 추가
|
||||
- COMPONENT_TYPE_LABELS 업데이트
|
||||
|
||||
✅ frontend/components/pop/designer/panels/ComponentPaletteV4.tsx
|
||||
- "줄바꿈" 컴포넌트 추가
|
||||
|
||||
✅ frontend/components/pop/designer/PopDesigner.tsx
|
||||
- (기존 Phase 2 변경사항 유지)
|
||||
```
|
||||
|
||||
### 문서 파일 (6개)
|
||||
```
|
||||
✅ popdocs/CHANGELOG.md
|
||||
- Phase 3 완료 기록
|
||||
|
||||
✅ popdocs/PLAN.md
|
||||
- Phase 3 체크 완료
|
||||
- Phase 4 계획 추가
|
||||
|
||||
✅ popdocs/V4_UNIFIED_DESIGN_SPEC.md
|
||||
- Phase 3 섹션 추가
|
||||
|
||||
✅ popdocs/components-spec.md
|
||||
- pop-break 상세 스펙 추가
|
||||
- Phase 3 업데이트 노트
|
||||
|
||||
✅ popdocs/README.md
|
||||
- 현재 상태 업데이트
|
||||
- Phase 3 요약 추가
|
||||
|
||||
✅ popdocs/decisions/002-phase3-visibility-break.md (신규)
|
||||
- 상세 설계 문서
|
||||
|
||||
✅ popdocs/PHASE3_SUMMARY.md (신규)
|
||||
- 이 문서
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 핵심 개념
|
||||
|
||||
### Flexbox 줄바꿈 원리
|
||||
```css
|
||||
/* 컨테이너 */
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap; /* 필수 */
|
||||
}
|
||||
|
||||
/* pop-break */
|
||||
.pop-break {
|
||||
flex-basis: 100%; /* 전체 너비 차지 → 다음 요소는 새 줄로 */
|
||||
height: 0; /* 실제로는 안 보임 */
|
||||
}
|
||||
```
|
||||
|
||||
### visibility vs hideBelow
|
||||
| 속성 | 제어 방식 | 용도 |
|
||||
|------|----------|------|
|
||||
| `visibility` | 모드별 명시적 | 특정 모드에서만 표시 (예: 모바일 전용) |
|
||||
| `hideBelow` | 픽셀 기반 자동 | 화면 너비에 따라 자동 숨김 (예: 500px 이하) |
|
||||
|
||||
**예시**:
|
||||
```typescript
|
||||
{
|
||||
visibility: {
|
||||
tablet_landscape: false, // 태블릿 가로: 무조건 숨김
|
||||
},
|
||||
hideBelow: 500, // 500px 이하: 자동 숨김 (다른 모드에서도)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
### Phase 4: 실제 컴포넌트 구현
|
||||
```
|
||||
우선순위:
|
||||
1. pop-field (입력/표시 필드)
|
||||
2. pop-button (액션 버튼)
|
||||
3. pop-list (데이터 리스트)
|
||||
4. pop-indicator (KPI 표시)
|
||||
5. pop-scanner (바코드/QR)
|
||||
6. pop-numpad (숫자 입력)
|
||||
```
|
||||
|
||||
### 추가 개선 사항
|
||||
```
|
||||
1. 컴포넌트 오버라이드 UI
|
||||
- 리스트 컬럼 수 조정 UI
|
||||
- 버튼 스타일 변경 UI
|
||||
- 필드 표시 형식 변경 UI
|
||||
|
||||
2. "모든 모드에 적용" 기능
|
||||
- 한 번에 모든 모드 체크/해제
|
||||
|
||||
3. 오버라이드 비교 뷰
|
||||
- 기본값 vs 오버라이드 차이 시각화
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ 주요 성과
|
||||
|
||||
1. ✅ **모드별 컴포넌트 제어**: visibility 속성으로 유연한 표시/숨김
|
||||
2. ✅ **Flexbox 줄바꿈 해결**: pop-break 컴포넌트로 업계 표준 달성
|
||||
3. ✅ **확장 가능한 구조**: 컴포넌트 오버라이드 병합으로 추후 기능 추가 용이
|
||||
4. ✅ **데이터 일관성**: 삭제 시 오버라이드 자동 정리로 데이터 무결성 유지
|
||||
5. ✅ **직관적인 UI**: 체크박스 기반 visibility 제어
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고 문서
|
||||
|
||||
- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계
|
||||
- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - v4 통합 설계
|
||||
- [CHANGELOG.md](./CHANGELOG.md) - 변경 이력
|
||||
- [PLAN.md](./PLAN.md) - 로드맵
|
||||
|
||||
---
|
||||
|
||||
*Phase 3 완료 - 2026-02-04*
|
||||
*다음: Phase 4 (실제 컴포넌트 구현)*
|
||||
|
|
@ -1,658 +0,0 @@
|
|||
# POP 화면 시스템 구현 계획서
|
||||
|
||||
## 개요
|
||||
|
||||
Vexplor 서비스 내에서 POP(Point of Production) 화면을 구성할 수 있는 시스템을 구현합니다.
|
||||
기존 Vexplor와 충돌 없이 별도 공간에서 개발하되, 장기적으로 통합 가능하도록 동일한 서비스 로직을 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
| 원칙 | 설명 |
|
||||
|------|------|
|
||||
| **충돌 방지** | POP 전용 공간에서 개발 |
|
||||
| **통합 준비** | 기본 서비스 로직은 Vexplor와 동일 |
|
||||
| **데이터 공유** | 같은 DB, 같은 데이터 소스 사용 |
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 개요
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ [데이터베이스] │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ screen_ │ │ screen_layouts_ │ │ screen_layouts_ │ │
|
||||
│ │ definitions │ │ v2 (데스크톱) │ │ pop (POP) │ │
|
||||
│ │ (공통) │ └─────────────────┘ └─────────────────┘ │
|
||||
│ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ [백엔드 API] │
|
||||
│ /screen-management/screens/:id/layout-v2 (데스크톱) │
|
||||
│ /screen-management/screens/:id/layout-pop (POP) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ [프론트엔드 - 데스크톱] │ │ [프론트엔드 - POP] │
|
||||
│ │ │ │
|
||||
│ app/(main)/ │ │ app/(pop)/ │
|
||||
│ lib/registry/ │ │ lib/registry/ │
|
||||
│ components/ │ │ pop-components/ │
|
||||
│ components/screen/ │ │ components/pop/ │
|
||||
└─────────────────────────┘ └─────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ PC 브라우저 │ │ 모바일/태블릿 브라우저 │
|
||||
│ (마우스 + 키보드) │ │ (터치 + 스캐너) │
|
||||
└─────────────────────────┘ └─────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 데이터베이스 변경사항
|
||||
|
||||
### 1-1. 테이블 추가/유지 현황
|
||||
|
||||
| 구분 | 테이블명 | 변경 내용 | 비고 |
|
||||
|------|----------|----------|------|
|
||||
| **추가** | `screen_layouts_pop` | POP 레이아웃 저장용 | 신규 테이블 |
|
||||
| **유지** | `screen_definitions` | 변경 없음 | 공통 사용 |
|
||||
| **유지** | `screen_layouts_v2` | 변경 없음 | 데스크톱 전용 |
|
||||
|
||||
### 1-2. 신규 테이블 DDL
|
||||
|
||||
```sql
|
||||
-- 마이그레이션 파일: db/migrations/XXX_create_screen_layouts_pop.sql
|
||||
|
||||
CREATE TABLE screen_layouts_pop (
|
||||
layout_id SERIAL PRIMARY KEY,
|
||||
screen_id INTEGER NOT NULL REFERENCES screen_definitions(screen_id),
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
layout_data JSONB NOT NULL DEFAULT '{}'::jsonb, -- 반응형 레이아웃 JSON
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_by VARCHAR(50),
|
||||
updated_by VARCHAR(50),
|
||||
UNIQUE(screen_id, company_code)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pop_screen_id ON screen_layouts_pop(screen_id);
|
||||
CREATE INDEX idx_pop_company_code ON screen_layouts_pop(company_code);
|
||||
|
||||
COMMENT ON TABLE screen_layouts_pop IS 'POP 화면 레이아웃 저장 테이블 (모바일/태블릿 반응형)';
|
||||
COMMENT ON COLUMN screen_layouts_pop.layout_data IS 'V2 형식의 레이아웃 JSON (반응형 구조)';
|
||||
```
|
||||
|
||||
### 1-3. 레이아웃 JSON 구조 (V2 형식 동일)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"components": [
|
||||
{
|
||||
"id": "comp_xxx",
|
||||
"url": "@/lib/registry/pop-components/pop-card-list",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"size": { "width": 100, "height": 50 },
|
||||
"displayOrder": 0,
|
||||
"overrides": {
|
||||
"tableName": "user_info",
|
||||
"columns": ["id", "name", "status"],
|
||||
"cardStyle": "compact"
|
||||
}
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-01-29T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 백엔드 변경사항
|
||||
|
||||
### 2-1. 파일 수정 목록
|
||||
|
||||
| 구분 | 파일 경로 | 변경 내용 |
|
||||
|------|----------|----------|
|
||||
| **수정** | `backend-node/src/services/screenManagementService.ts` | POP 레이아웃 CRUD 함수 추가 |
|
||||
| **수정** | `backend-node/src/routes/screenManagementRoutes.ts` | POP API 엔드포인트 추가 |
|
||||
|
||||
### 2-2. 추가 API 엔드포인트
|
||||
|
||||
```
|
||||
GET /screen-management/screens/:screenId/layout-pop # POP 레이아웃 조회
|
||||
POST /screen-management/screens/:screenId/layout-pop # POP 레이아웃 저장
|
||||
DELETE /screen-management/screens/:screenId/layout-pop # POP 레이아웃 삭제
|
||||
```
|
||||
|
||||
### 2-3. screenManagementService.ts 추가 함수
|
||||
|
||||
```typescript
|
||||
// 기존 함수 (유지)
|
||||
getScreenLayoutV2(screenId, companyCode)
|
||||
saveLayoutV2(screenId, companyCode, layoutData)
|
||||
|
||||
// 추가 함수 (신규) - 로직은 V2와 동일, 테이블명만 다름
|
||||
getScreenLayoutPop(screenId, companyCode)
|
||||
saveLayoutPop(screenId, companyCode, layoutData)
|
||||
deleteLayoutPop(screenId, companyCode)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 프론트엔드 변경사항
|
||||
|
||||
### 3-1. 폴더 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── app/
|
||||
│ └── (pop)/ # [기존] POP 라우팅 그룹
|
||||
│ ├── layout.tsx # [수정] POP 전용 레이아웃
|
||||
│ ├── pop/
|
||||
│ │ └── page.tsx # [기존] POP 메인
|
||||
│ └── screens/ # [추가] POP 화면 뷰어
|
||||
│ └── [screenId]/
|
||||
│ └── page.tsx # [추가] POP 동적 화면
|
||||
│
|
||||
├── lib/
|
||||
│ ├── api/
|
||||
│ │ └── screen.ts # [수정] POP API 함수 추가
|
||||
│ │
|
||||
│ ├── registry/
|
||||
│ │ ├── pop-components/ # [추가] POP 전용 컴포넌트
|
||||
│ │ │ ├── pop-card-list/
|
||||
│ │ │ │ ├── PopCardListComponent.tsx
|
||||
│ │ │ │ ├── PopCardListConfigPanel.tsx
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ ├── pop-touch-button/
|
||||
│ │ │ ├── pop-scanner-input/
|
||||
│ │ │ └── index.ts # POP 컴포넌트 내보내기
|
||||
│ │ │
|
||||
│ │ ├── PopComponentRegistry.ts # [추가] POP 컴포넌트 레지스트리
|
||||
│ │ └── ComponentRegistry.ts # [유지] 기존 유지
|
||||
│ │
|
||||
│ ├── schemas/
|
||||
│ │ └── popComponentConfig.ts # [추가] POP용 Zod 스키마
|
||||
│ │
|
||||
│ └── utils/
|
||||
│ └── layoutPopConverter.ts # [추가] POP 레이아웃 변환기
|
||||
│
|
||||
└── components/
|
||||
└── pop/ # [기존] POP UI 컴포넌트
|
||||
├── PopScreenDesigner.tsx # [추가] POP 화면 설계 도구
|
||||
├── PopPreview.tsx # [추가] POP 미리보기
|
||||
└── PopDynamicRenderer.tsx # [추가] POP 동적 렌더러
|
||||
```
|
||||
|
||||
### 3-2. 파일별 상세 내용
|
||||
|
||||
#### A. 신규 파일 (추가)
|
||||
|
||||
| 파일 | 역할 | 기반 |
|
||||
|------|------|------|
|
||||
| `app/(pop)/screens/[screenId]/page.tsx` | POP 화면 뷰어 | `app/(main)/screens/[screenId]/page.tsx` 참고 |
|
||||
| `lib/registry/PopComponentRegistry.ts` | POP 컴포넌트 등록 | `ComponentRegistry.ts` 구조 동일 |
|
||||
| `lib/registry/pop-components/*` | POP 전용 컴포넌트 | 신규 개발 |
|
||||
| `lib/schemas/popComponentConfig.ts` | POP Zod 스키마 | `componentConfig.ts` 구조 동일 |
|
||||
| `lib/utils/layoutPopConverter.ts` | POP 레이아웃 변환 | `layoutV2Converter.ts` 구조 동일 |
|
||||
| `components/pop/PopScreenDesigner.tsx` | POP 화면 설계 | 신규 개발 |
|
||||
| `components/pop/PopDynamicRenderer.tsx` | POP 동적 렌더러 | `DynamicComponentRenderer.tsx` 참고 |
|
||||
|
||||
#### B. 수정 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `lib/api/screen.ts` | `getLayoutPop()`, `saveLayoutPop()` 함수 추가 |
|
||||
| `app/(pop)/layout.tsx` | POP 전용 레이아웃 스타일 적용 |
|
||||
|
||||
#### C. 유지 파일 (변경 없음)
|
||||
|
||||
| 파일 | 이유 |
|
||||
|------|------|
|
||||
| `lib/registry/ComponentRegistry.ts` | 데스크톱 전용, 분리 유지 |
|
||||
| `lib/schemas/componentConfig.ts` | 데스크톱 전용, 분리 유지 |
|
||||
| `lib/utils/layoutV2Converter.ts` | 데스크톱 전용, 분리 유지 |
|
||||
| `app/(main)/*` | 데스크톱 전용, 변경 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 서비스 로직 흐름
|
||||
|
||||
### 4-1. 데스크톱 (기존 - 변경 없음)
|
||||
|
||||
```
|
||||
[사용자] → /screens/123 접속
|
||||
↓
|
||||
[app/(main)/screens/[screenId]/page.tsx]
|
||||
↓
|
||||
[getLayoutV2(screenId)] → API 호출
|
||||
↓
|
||||
[screen_layouts_v2 테이블] → 레이아웃 JSON 반환
|
||||
↓
|
||||
[DynamicComponentRenderer] → 컴포넌트 렌더링
|
||||
↓
|
||||
[ComponentRegistry] → 컴포넌트 찾기
|
||||
↓
|
||||
[lib/registry/components/table-list] → 컴포넌트 실행
|
||||
↓
|
||||
[화면 표시]
|
||||
```
|
||||
|
||||
### 4-2. POP (신규 - 동일 로직)
|
||||
|
||||
```
|
||||
[사용자] → /pop/screens/123 접속
|
||||
↓
|
||||
[app/(pop)/screens/[screenId]/page.tsx]
|
||||
↓
|
||||
[getLayoutPop(screenId)] → API 호출
|
||||
↓
|
||||
[screen_layouts_pop 테이블] → 레이아웃 JSON 반환
|
||||
↓
|
||||
[PopDynamicRenderer] → 컴포넌트 렌더링
|
||||
↓
|
||||
[PopComponentRegistry] → 컴포넌트 찾기
|
||||
↓
|
||||
[lib/registry/pop-components/pop-card-list] → 컴포넌트 실행
|
||||
↓
|
||||
[화면 표시]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 로직 변경 여부
|
||||
|
||||
| 구분 | 로직 변경 | 설명 |
|
||||
|------|----------|------|
|
||||
| 데이터베이스 CRUD | **없음** | 동일한 SELECT/INSERT/UPDATE 패턴 |
|
||||
| API 호출 방식 | **없음** | 동일한 REST API 패턴 |
|
||||
| 컴포넌트 렌더링 | **없음** | 동일한 URL 기반 + overrides 방식 |
|
||||
| Zod 스키마 검증 | **없음** | 동일한 검증 로직 |
|
||||
| 레이아웃 JSON 구조 | **없음** | 동일한 V2 JSON 구조 사용 |
|
||||
|
||||
**결론: 로직 변경 없음, 파일/테이블 분리만 진행**
|
||||
|
||||
---
|
||||
|
||||
## 6. 데스크톱 vs POP 비교
|
||||
|
||||
| 구분 | Vexplor (데스크톱) | POP (모바일/태블릿) |
|
||||
|------|-------------------|---------------------|
|
||||
| **타겟 기기** | PC (마우스+키보드) | 모바일/태블릿 (터치) |
|
||||
| **화면 크기** | 1920x1080 고정 | 반응형 (다양한 크기) |
|
||||
| **UI 스타일** | 테이블 중심, 작은 버튼 | 카드 중심, 큰 터치 버튼 |
|
||||
| **입력 방식** | 키보드 타이핑 | 터치, 스캐너, 음성 |
|
||||
| **사용 환경** | 사무실 | 현장, 창고, 공장 |
|
||||
| **레이아웃 테이블** | `screen_layouts_v2` | `screen_layouts_pop` |
|
||||
| **컴포넌트 경로** | `lib/registry/components/` | `lib/registry/pop-components/` |
|
||||
| **레지스트리** | `ComponentRegistry.ts` | `PopComponentRegistry.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 장기 통합 시나리오
|
||||
|
||||
### Phase 1: 분리 개발 (현재 목표)
|
||||
|
||||
```
|
||||
[데스크톱] [POP]
|
||||
ComponentRegistry PopComponentRegistry
|
||||
components/ pop-components/
|
||||
screen_layouts_v2 screen_layouts_pop
|
||||
```
|
||||
|
||||
### Phase 2: 부분 통합 (향후)
|
||||
|
||||
```
|
||||
[통합 가능한 부분]
|
||||
- 공통 유틸리티 함수
|
||||
- 공통 Zod 스키마
|
||||
- 공통 타입 정의
|
||||
|
||||
[분리 유지]
|
||||
- 플랫폼별 컴포넌트
|
||||
- 플랫폼별 레이아웃
|
||||
```
|
||||
|
||||
### Phase 3: 완전 통합 (최종)
|
||||
|
||||
```
|
||||
[단일 컴포넌트 레지스트리]
|
||||
ComponentRegistry
|
||||
├── components/ (공통)
|
||||
├── desktop-components/ (데스크톱 전용)
|
||||
└── pop-components/ (POP 전용)
|
||||
|
||||
[단일 레이아웃 테이블] (선택사항)
|
||||
screen_layouts
|
||||
├── platform = 'desktop'
|
||||
└── platform = 'pop'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. V2 공통 요소 (통합 핵심)
|
||||
|
||||
POP과 데스크톱이 장기적으로 통합될 수 있는 **핵심 기반**입니다.
|
||||
|
||||
### 8-1. 공통 유틸리티 함수
|
||||
|
||||
**파일 위치:** `frontend/lib/schemas/componentConfig.ts`, `frontend/lib/utils/layoutV2Converter.ts`
|
||||
|
||||
#### 핵심 병합/추출 함수 (가장 중요!)
|
||||
|
||||
| 함수명 | 역할 | 사용 시점 |
|
||||
|--------|------|----------|
|
||||
| `deepMerge()` | 객체 깊은 병합 | 기본값 + overrides 합칠 때 |
|
||||
| `mergeComponentConfig()` | 기본값 + 커스텀 병합 | **렌더링 시** (화면 표시) |
|
||||
| `extractCustomConfig()` | 기본값과 다른 부분만 추출 | **저장 시** (DB 저장) |
|
||||
| `isDeepEqual()` | 두 객체 깊은 비교 | 변경 여부 판단 |
|
||||
|
||||
```typescript
|
||||
// 예시: 저장 시 차이값만 추출
|
||||
const defaults = { showHeader: true, pageSize: 20 };
|
||||
const fullConfig = { showHeader: true, pageSize: 50, customField: "test" };
|
||||
const overrides = extractCustomConfig(fullConfig, defaults);
|
||||
// 결과: { pageSize: 50, customField: "test" } (차이값만!)
|
||||
```
|
||||
|
||||
#### URL 처리 함수
|
||||
|
||||
| 함수명 | 역할 | 예시 |
|
||||
|--------|------|------|
|
||||
| `getComponentUrl()` | 타입 → URL 변환 | `"v2-table-list"` → `"@/lib/registry/components/v2-table-list"` |
|
||||
| `getComponentTypeFromUrl()` | URL → 타입 추출 | `"@/lib/registry/components/v2-table-list"` → `"v2-table-list"` |
|
||||
|
||||
#### 기본값 조회 함수
|
||||
|
||||
| 함수명 | 역할 |
|
||||
|--------|------|
|
||||
| `getComponentDefaults()` | 컴포넌트 타입으로 기본값 조회 |
|
||||
| `getDefaultsByUrl()` | URL로 기본값 조회 |
|
||||
|
||||
#### V2 로드/저장 함수 (핵심!)
|
||||
|
||||
| 함수명 | 역할 | 사용 시점 |
|
||||
|--------|------|----------|
|
||||
| `loadComponentV2()` | 컴포넌트 로드 (기본값 병합) | DB → 화면 |
|
||||
| `saveComponentV2()` | 컴포넌트 저장 (차이값 추출) | 화면 → DB |
|
||||
| `loadLayoutV2()` | 레이아웃 전체 로드 | DB → 화면 |
|
||||
| `saveLayoutV2()` | 레이아웃 전체 저장 | 화면 → DB |
|
||||
|
||||
#### 변환 함수
|
||||
|
||||
| 함수명 | 역할 |
|
||||
|--------|------|
|
||||
| `convertV2ToLegacy()` | V2 → Legacy 변환 (하위 호환) |
|
||||
| `convertLegacyToV2()` | Legacy → V2 변환 |
|
||||
| `isValidV2Layout()` | V2 레이아웃인지 검증 |
|
||||
| `isLegacyLayout()` | 레거시 레이아웃인지 확인 |
|
||||
|
||||
### 8-2. 공통 Zod 스키마
|
||||
|
||||
**파일 위치:** `frontend/lib/schemas/componentConfig.ts`
|
||||
|
||||
#### 핵심 스키마 (필수!)
|
||||
|
||||
```typescript
|
||||
// 컴포넌트 기본 구조
|
||||
export const componentV2Schema = z.object({
|
||||
id: z.string(),
|
||||
url: z.string(),
|
||||
position: z.object({ x: z.number(), y: z.number() }),
|
||||
size: z.object({ width: z.number(), height: z.number() }),
|
||||
displayOrder: z.number().default(0),
|
||||
overrides: z.record(z.string(), z.any()).default({}),
|
||||
});
|
||||
|
||||
// 레이아웃 기본 구조
|
||||
export const layoutV2Schema = z.object({
|
||||
version: z.string().default("2.0"),
|
||||
components: z.array(componentV2Schema).default([]),
|
||||
updatedAt: z.string().optional(),
|
||||
screenResolution: z.object({...}).optional(),
|
||||
gridSettings: z.any().optional(),
|
||||
});
|
||||
```
|
||||
|
||||
#### 컴포넌트별 overrides 스키마 (25개+)
|
||||
|
||||
| 스키마명 | 컴포넌트 | 주요 기본값 |
|
||||
|----------|----------|------------|
|
||||
| `v2TableListOverridesSchema` | 테이블 리스트 | displayMode: "table", pageSize: 20 |
|
||||
| `v2ButtonPrimaryOverridesSchema` | 버튼 | text: "저장", variant: "primary" |
|
||||
| `v2SplitPanelLayoutOverridesSchema` | 분할 레이아웃 | splitRatio: 30, resizable: true |
|
||||
| `v2SectionCardOverridesSchema` | 섹션 카드 | padding: "md", collapsible: false |
|
||||
| `v2TabsWidgetOverridesSchema` | 탭 위젯 | orientation: "horizontal" |
|
||||
| `v2RepeaterOverridesSchema` | 리피터 | renderMode: "inline" |
|
||||
|
||||
#### 스키마 레지스트리 (자동 매핑)
|
||||
|
||||
```typescript
|
||||
const componentOverridesSchemaRegistry = {
|
||||
"v2-table-list": v2TableListOverridesSchema,
|
||||
"v2-button-primary": v2ButtonPrimaryOverridesSchema,
|
||||
"v2-split-panel-layout": v2SplitPanelLayoutOverridesSchema,
|
||||
// ... 25개+ 컴포넌트
|
||||
};
|
||||
```
|
||||
|
||||
### 8-3. 공통 타입 정의
|
||||
|
||||
**파일 위치:** `frontend/types/v2-core.ts`, `frontend/types/v2-components.ts`
|
||||
|
||||
#### 핵심 공통 타입 (v2-core.ts)
|
||||
|
||||
```typescript
|
||||
// 웹 입력 타입
|
||||
export type WebType =
|
||||
| "text" | "textarea" | "email" | "tel" | "url"
|
||||
| "number" | "decimal"
|
||||
| "date" | "datetime"
|
||||
| "select" | "dropdown" | "radio" | "checkbox" | "boolean"
|
||||
| "code" | "entity" | "file" | "image" | "button"
|
||||
| "container" | "group" | "list" | "tree" | "custom";
|
||||
|
||||
// 버튼 액션 타입
|
||||
export type ButtonActionType =
|
||||
| "save" | "cancel" | "delete" | "edit" | "copy" | "add"
|
||||
| "search" | "reset" | "submit"
|
||||
| "close" | "popup" | "modal"
|
||||
| "navigate" | "newWindow"
|
||||
| "control" | "transferData" | "quickInsert";
|
||||
|
||||
// 위치/크기
|
||||
export interface Position { x: number; y: number; z?: number; }
|
||||
export interface Size { width: number; height: number; }
|
||||
|
||||
// 공통 스타일
|
||||
export interface CommonStyle {
|
||||
margin?: string;
|
||||
padding?: string;
|
||||
border?: string;
|
||||
backgroundColor?: string;
|
||||
color?: string;
|
||||
fontSize?: string;
|
||||
// ... 30개+ 속성
|
||||
}
|
||||
|
||||
// 유효성 검사
|
||||
export interface ValidationRule {
|
||||
type: "required" | "minLength" | "maxLength" | "pattern" | "min" | "max" | "email" | "url";
|
||||
value?: unknown;
|
||||
message: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### V2 컴포넌트 타입 (v2-components.ts)
|
||||
|
||||
```typescript
|
||||
// 10개 통합 컴포넌트 타입
|
||||
export type V2ComponentType =
|
||||
| "V2Input" | "V2Select" | "V2Date" | "V2Text" | "V2Media"
|
||||
| "V2List" | "V2Layout" | "V2Group" | "V2Biz" | "V2Hierarchy";
|
||||
|
||||
// 공통 속성
|
||||
export interface V2BaseProps {
|
||||
id: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
tableName?: string;
|
||||
columnName?: string;
|
||||
position?: Position;
|
||||
size?: Size;
|
||||
style?: CommonStyle;
|
||||
validation?: ValidationRule[];
|
||||
}
|
||||
```
|
||||
|
||||
### 8-4. POP 통합 시 공유/분리 기준
|
||||
|
||||
#### 반드시 공유 (그대로 사용)
|
||||
|
||||
| 구분 | 파일/요소 | 이유 |
|
||||
|------|----------|------|
|
||||
| **유틸리티** | `deepMerge`, `extractCustomConfig`, `mergeComponentConfig` | 저장/로드 로직 동일 |
|
||||
| **스키마** | `componentV2Schema`, `layoutV2Schema` | JSON 구조 동일 |
|
||||
| **타입** | `Position`, `Size`, `WebType`, `ButtonActionType` | 기본 구조 동일 |
|
||||
|
||||
#### POP 전용으로 분리
|
||||
|
||||
| 구분 | 파일/요소 | 이유 |
|
||||
|------|----------|------|
|
||||
| **overrides 스키마** | `popCardListOverridesSchema` 등 | POP 컴포넌트 전용 기본값 |
|
||||
| **스키마 레지스트리** | `popComponentOverridesSchemaRegistry` | POP 컴포넌트 매핑 |
|
||||
| **기본값 레지스트리** | `popComponentDefaultsRegistry` | POP 컴포넌트 기본값 |
|
||||
|
||||
### 8-5. 추천 폴더 구조 (공유 분리)
|
||||
|
||||
```
|
||||
frontend/lib/schemas/
|
||||
├── componentConfig.ts # 기존 (데스크톱)
|
||||
├── popComponentConfig.ts # 신규 (POP) - 구조는 동일
|
||||
└── shared/ # 신규 (공유) - 향후 통합 시
|
||||
├── baseSchemas.ts # componentV2Schema, layoutV2Schema
|
||||
├── mergeUtils.ts # deepMerge, extractCustomConfig 등
|
||||
└── types.ts # Position, Size 등
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 작업 우선순위
|
||||
|
||||
### [ ] 1단계: 데이터베이스
|
||||
|
||||
- [ ] `screen_layouts_pop` 테이블 생성 마이그레이션 작성
|
||||
- [ ] 마이그레이션 실행 및 검증
|
||||
|
||||
### [ ] 2단계: 백엔드 API
|
||||
|
||||
- [ ] `screenManagementService.ts`에 POP 함수 추가
|
||||
- [ ] `getScreenLayoutPop()`
|
||||
- [ ] `saveLayoutPop()`
|
||||
- [ ] `deleteLayoutPop()`
|
||||
- [ ] `screenManagementRoutes.ts`에 엔드포인트 추가
|
||||
- [ ] `GET /screens/:screenId/layout-pop`
|
||||
- [ ] `POST /screens/:screenId/layout-pop`
|
||||
- [ ] `DELETE /screens/:screenId/layout-pop`
|
||||
|
||||
### [ ] 3단계: 프론트엔드 기반
|
||||
|
||||
- [ ] `lib/api/screen.ts`에 POP API 함수 추가
|
||||
- [ ] `getLayoutPop()`
|
||||
- [ ] `saveLayoutPop()`
|
||||
- [ ] `lib/registry/PopComponentRegistry.ts` 생성
|
||||
- [ ] `lib/schemas/popComponentConfig.ts` 생성
|
||||
- [ ] `lib/utils/layoutPopConverter.ts` 생성
|
||||
|
||||
### [ ] 4단계: POP 컴포넌트 개발
|
||||
|
||||
- [ ] `lib/registry/pop-components/` 폴더 구조 생성
|
||||
- [ ] 기본 컴포넌트 개발
|
||||
- [ ] `pop-card-list` (카드형 리스트)
|
||||
- [ ] `pop-touch-button` (터치 버튼)
|
||||
- [ ] `pop-scanner-input` (스캐너 입력)
|
||||
- [ ] `pop-status-badge` (상태 배지)
|
||||
|
||||
### [ ] 5단계: POP 화면 페이지
|
||||
|
||||
- [ ] `app/(pop)/screens/[screenId]/page.tsx` 생성
|
||||
- [ ] `components/pop/PopDynamicRenderer.tsx` 생성
|
||||
- [ ] `app/(pop)/layout.tsx` 수정 (POP 전용 스타일)
|
||||
|
||||
### [ ] 6단계: POP 화면 디자이너 (선택)
|
||||
|
||||
- [ ] `components/pop/PopScreenDesigner.tsx` 생성
|
||||
- [ ] `components/pop/PopPreview.tsx` 생성
|
||||
- [ ] 관리자 메뉴에 POP 화면 설계 기능 추가
|
||||
|
||||
---
|
||||
|
||||
## 10. 참고 파일 위치
|
||||
|
||||
### 데스크톱 참고 파일 (기존)
|
||||
|
||||
| 구분 | 파일 경로 |
|
||||
|------|----------|
|
||||
| 화면 페이지 | `frontend/app/(main)/screens/[screenId]/page.tsx` |
|
||||
| 컴포넌트 레지스트리 | `frontend/lib/registry/ComponentRegistry.ts` |
|
||||
| 동적 렌더러 | `frontend/lib/registry/DynamicComponentRenderer.tsx` |
|
||||
| Zod 스키마 | `frontend/lib/schemas/componentConfig.ts` |
|
||||
| 레이아웃 변환기 | `frontend/lib/utils/layoutV2Converter.ts` |
|
||||
| 화면 API | `frontend/lib/api/screen.ts` |
|
||||
| 백엔드 서비스 | `backend-node/src/services/screenManagementService.ts` |
|
||||
| 백엔드 라우트 | `backend-node/src/routes/screenManagementRoutes.ts` |
|
||||
|
||||
### 관련 문서
|
||||
|
||||
| 문서 | 경로 |
|
||||
|------|------|
|
||||
| V2 아키텍처 | `docs/DDD1542/COMPONENT_LAYOUT_V2_ARCHITECTURE.md` |
|
||||
| 화면관리 설계 | `docs/kjs/화면관리_시스템_설계.md` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 주의사항
|
||||
|
||||
### 멀티테넌시
|
||||
|
||||
- 모든 테이블에 `company_code` 필수
|
||||
- 모든 쿼리에 `company_code` 필터링 적용
|
||||
- 최고 관리자(`company_code = "*"`)는 모든 데이터 조회 가능
|
||||
|
||||
### 충돌 방지
|
||||
|
||||
- 기존 데스크톱 파일 수정 최소화
|
||||
- POP 전용 폴더/파일에서 작업
|
||||
- 공통 로직은 별도 유틸리티로 분리
|
||||
|
||||
### 테스트
|
||||
|
||||
- 데스크톱 기능 회귀 테스트 필수
|
||||
- POP 반응형 테스트 (모바일/태블릿)
|
||||
- 멀티테넌시 격리 테스트
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 버전 | 내용 |
|
||||
|------|------|------|
|
||||
| 2026-01-29 | 1.0 | 초기 계획서 작성 |
|
||||
| 2026-01-29 | 1.1 | V2 공통 요소 (통합 핵심) 섹션 추가 |
|
||||
|
||||
---
|
||||
|
||||
## 작성자
|
||||
|
||||
- 작성일: 2026-01-29
|
||||
- 프로젝트: Vexplor POP 화면 시스템
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,760 +0,0 @@
|
|||
# POP v4.0 제약조건 기반 시스템 구현 계획
|
||||
|
||||
## 1. 현재 시스템 분석
|
||||
|
||||
### 1.1 현재 구조 (v3.0)
|
||||
|
||||
```typescript
|
||||
// 4개 모드별 그리드 위치 기반
|
||||
interface PopLayoutDataV3 {
|
||||
version: "pop-3.0";
|
||||
layouts: {
|
||||
tablet_landscape: { componentPositions: Record<string, GridPosition> };
|
||||
tablet_portrait: { componentPositions: Record<string, GridPosition> };
|
||||
mobile_landscape: { componentPositions: Record<string, GridPosition> };
|
||||
mobile_portrait: { componentPositions: Record<string, GridPosition> };
|
||||
};
|
||||
components: Record<string, PopComponentDefinition>;
|
||||
// ...
|
||||
}
|
||||
|
||||
interface GridPosition {
|
||||
col: number; // 1-based
|
||||
row: number; // 1-based
|
||||
colSpan: number;
|
||||
rowSpan: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 현재 문제점
|
||||
|
||||
1. **4배 작업량**: 4개 모드 각각 설계 필요
|
||||
2. **수동 동기화**: 컴포넌트 추가/삭제 시 4모드 수동 동기화
|
||||
3. **그리드 한계**: col/row 기반이라 자동 재배치 불가
|
||||
4. **반응형 미흡**: 화면 크기 변화에 자동 적응 불가
|
||||
5. **디바이스 차이 무시**: 태블릿/모바일 물리적 크기 차이 고려 안됨
|
||||
|
||||
---
|
||||
|
||||
## 2. 새로운 시스템 설계 (v4.0)
|
||||
|
||||
### 2.1 핵심 철학
|
||||
|
||||
```
|
||||
"하나의 레이아웃 설계 → 제약조건 설정 → 모든 화면 자동 적응"
|
||||
```
|
||||
|
||||
- **단일 소스**: 1개 레이아웃만 설계
|
||||
- **제약조건 기반**: 컴포넌트가 "어떻게 반응할지" 규칙 정의
|
||||
- **Flexbox 렌더링**: CSS Grid에서 Flexbox 기반으로 전환
|
||||
- **자동 줄바꿈**: 공간 부족 시 자동 재배치
|
||||
|
||||
### 2.2 새로운 데이터 구조
|
||||
|
||||
```typescript
|
||||
// v4.0 레이아웃
|
||||
interface PopLayoutDataV4 {
|
||||
version: "pop-4.0";
|
||||
|
||||
// 루트 컨테이너
|
||||
root: PopContainer;
|
||||
|
||||
// 컴포넌트 정의 (ID → 정의)
|
||||
components: Record<string, PopComponentDefinitionV4>;
|
||||
|
||||
// 데이터 흐름
|
||||
dataFlow: PopDataFlow;
|
||||
|
||||
// 전역 설정
|
||||
settings: PopGlobalSettingsV4;
|
||||
|
||||
// 메타데이터
|
||||
metadata?: PopLayoutMetadata;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 컨테이너 (스택)
|
||||
|
||||
```typescript
|
||||
// 컨테이너: 컴포넌트들을 담는 그룹
|
||||
interface PopContainer {
|
||||
id: string;
|
||||
type: "stack";
|
||||
|
||||
// 스택 방향
|
||||
direction: "horizontal" | "vertical";
|
||||
|
||||
// 줄바꿈 허용
|
||||
wrap: boolean;
|
||||
|
||||
// 요소 간 간격
|
||||
gap: number;
|
||||
|
||||
// 정렬
|
||||
alignItems: "start" | "center" | "end" | "stretch";
|
||||
justifyContent: "start" | "center" | "end" | "space-between" | "space-around";
|
||||
|
||||
// 패딩
|
||||
padding?: {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
// 반응형 규칙 (선택)
|
||||
responsive?: {
|
||||
// 브레이크포인트 (이 너비 이하에서 적용)
|
||||
breakpoint: number;
|
||||
// 변경할 방향
|
||||
direction?: "horizontal" | "vertical";
|
||||
// 변경할 간격
|
||||
gap?: number;
|
||||
}[];
|
||||
|
||||
// 자식 요소 (컴포넌트 ID 또는 중첩 컨테이너)
|
||||
children: (string | PopContainer)[];
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 컴포넌트 제약조건
|
||||
|
||||
```typescript
|
||||
interface PopComponentDefinitionV4 {
|
||||
id: string;
|
||||
type: PopComponentType;
|
||||
label?: string;
|
||||
|
||||
// ===== 크기 제약 (핵심) =====
|
||||
size: {
|
||||
// 너비 모드
|
||||
width: "fixed" | "fill" | "hug";
|
||||
// 높이 모드
|
||||
height: "fixed" | "fill" | "hug";
|
||||
|
||||
// 고정 크기 (width/height가 fixed일 때)
|
||||
fixedWidth?: number;
|
||||
fixedHeight?: number;
|
||||
|
||||
// 최소/최대 크기
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
|
||||
// 비율 (fill일 때, 같은 컨테이너 내 다른 요소와의 비율)
|
||||
flexGrow?: number; // 기본 1
|
||||
flexShrink?: number; // 기본 1
|
||||
};
|
||||
|
||||
// ===== 정렬 =====
|
||||
alignSelf?: "start" | "center" | "end" | "stretch";
|
||||
|
||||
// ===== 여백 =====
|
||||
margin?: {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
// ===== 모바일 스케일 (선택) =====
|
||||
// 모바일에서 컴포넌트를 더 크게 표시
|
||||
mobileScale?: number; // 기본 1.0, 예: 1.2 = 20% 더 크게
|
||||
|
||||
// ===== 기존 속성 =====
|
||||
dataBinding?: PopDataBinding;
|
||||
style?: PopStylePreset;
|
||||
config?: PopComponentConfig;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 크기 모드 설명
|
||||
|
||||
| 모드 | 설명 | CSS 변환 |
|
||||
|------|------|----------|
|
||||
| `fixed` | 고정 크기 (px) | `width: {fixedWidth}px` |
|
||||
| `fill` | 부모 공간 채우기 | `flex: {flexGrow} {flexShrink} 0` |
|
||||
| `hug` | 내용에 맞춤 | `flex: 0 0 auto` |
|
||||
|
||||
### 2.6 전역 설정
|
||||
|
||||
```typescript
|
||||
interface PopGlobalSettingsV4 {
|
||||
// 기본 터치 타겟 크기
|
||||
touchTargetMin: number; // 48px
|
||||
|
||||
// 모드 (일반/산업현장)
|
||||
mode: "normal" | "industrial";
|
||||
|
||||
// 기본 간격
|
||||
defaultGap: number; // 8px
|
||||
|
||||
// 기본 패딩
|
||||
defaultPadding: number; // 16px
|
||||
|
||||
// 반응형 브레이크포인트 (전역)
|
||||
breakpoints: {
|
||||
tablet: number; // 768px
|
||||
mobile: number; // 480px
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 디자이너 UI 변경
|
||||
|
||||
### 3.1 기존 디자이너 vs 새 디자이너
|
||||
|
||||
```
|
||||
기존 (그리드 기반):
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ [태블릿 가로] [태블릿 세로] [모바일 가로] [모바일 세로] │
|
||||
│ │
|
||||
│ 24x24 그리드에 컴포넌트 드래그 배치 │
|
||||
│ │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
새로운 (제약조건 기반):
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ [단일 캔버스] 미리보기: [태블릿▼] │
|
||||
│ │
|
||||
│ 스택(컨테이너)에 컴포넌트 배치 │
|
||||
│ + 우측 패널에서 제약조건 설정 │
|
||||
│ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 새로운 디자이너 레이아웃
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ POP 화면 디자이너 v4 [저장] [미리보기] │
|
||||
├────────────────┬────────────────────────┬───────────────────────┤
|
||||
│ │ │ │
|
||||
│ 컴포넌트 │ 캔버스 │ 속성 패널 │
|
||||
│ │ │ │
|
||||
│ ▼ 기본 │ ┌──────────────────┐ │ ▼ 선택됨: 입력창 │
|
||||
│ [필드] │ │ ┌──────────────┐ │ │ │
|
||||
│ [버튼] │ │ │입력창 │ │ │ ▼ 크기 │
|
||||
│ [리스트] │ │ └──────────────┘ │ │ 너비: [채우기 ▼] │
|
||||
│ [인디케이터] │ │ │ │ 최소: [100] px │
|
||||
│ │ │ ┌─────┐ ┌─────┐ │ │ 최대: [없음] │
|
||||
│ ▼ 입력 │ │ │버튼1│ │버튼2│ │ │ │
|
||||
│ [스캐너] │ │ └─────┘ └─────┘ │ │ 높이: [고정 ▼] │
|
||||
│ [숫자패드] │ │ │ │ 값: [48] px │
|
||||
│ │ └──────────────────┘ │ │
|
||||
│ ───────── │ │ ▼ 정렬 │
|
||||
│ │ 미리보기: │ [늘이기 ▼] │
|
||||
│ ▼ 레이아웃 │ ┌──────────────────┐ │ │
|
||||
│ [스택 (가로)] │ │[태블릿 가로 ▼] │ │ ▼ 여백 │
|
||||
│ [스택 (세로)] │ │[768px] │ │ 상[8] 우[0] 하[8] 좌[0]│
|
||||
│ │ └──────────────────┘ │ │
|
||||
│ │ │ ▼ 반응형 │
|
||||
│ │ │ 모바일 스케일: [1.2] │
|
||||
│ │ │ │
|
||||
└────────────────┴────────────────────────┴───────────────────────┘
|
||||
```
|
||||
|
||||
### 3.3 컨테이너(스택) 편집
|
||||
|
||||
```
|
||||
┌─ 스택 속성 ─────────────────────┐
|
||||
│ │
|
||||
│ 방향: [가로 ▼] │
|
||||
│ 줄바꿈: [허용 ☑] │
|
||||
│ 간격: [8] px │
|
||||
│ │
|
||||
│ 정렬 (가로): [가운데 ▼] │
|
||||
│ 정렬 (세로): [늘이기 ▼] │
|
||||
│ │
|
||||
│ ▼ 반응형 규칙 │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ 768px 이하: 세로 방향 │ │
|
||||
│ │ [+ 규칙 추가] │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 렌더링 로직 변경
|
||||
|
||||
### 4.1 기존 렌더링 (CSS Grid)
|
||||
|
||||
```typescript
|
||||
// v3: CSS Grid 기반
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(24, 1fr)`,
|
||||
gridTemplateRows: `repeat(24, 1fr)`,
|
||||
gap: "4px",
|
||||
}}>
|
||||
{componentIds.map(id => (
|
||||
<div style={{
|
||||
gridColumn: `${pos.col} / span ${pos.colSpan}`,
|
||||
gridRow: `${pos.row} / span ${pos.rowSpan}`,
|
||||
}}>
|
||||
<Component />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4.2 새로운 렌더링 (Flexbox)
|
||||
|
||||
```typescript
|
||||
// v4: Flexbox 기반
|
||||
function renderContainer(container: PopContainer, components: Record<string, PopComponentDefinitionV4>) {
|
||||
const direction = useResponsiveValue(container, 'direction');
|
||||
const gap = useResponsiveValue(container, 'gap');
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: direction === "horizontal" ? "row" : "column",
|
||||
flexWrap: container.wrap ? "wrap" : "nowrap",
|
||||
gap: `${gap}px`,
|
||||
alignItems: container.alignItems,
|
||||
justifyContent: container.justifyContent,
|
||||
padding: container.padding ?
|
||||
`${container.padding.top}px ${container.padding.right}px ${container.padding.bottom}px ${container.padding.left}px`
|
||||
: undefined,
|
||||
}}>
|
||||
{container.children.map(child => {
|
||||
if (typeof child === "string") {
|
||||
// 컴포넌트 렌더링
|
||||
return renderComponent(components[child]);
|
||||
} else {
|
||||
// 중첩 컨테이너 렌더링
|
||||
return renderContainer(child, components);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderComponent(component: PopComponentDefinitionV4) {
|
||||
const { size, margin, mobileScale } = component;
|
||||
const isMobile = useIsMobile();
|
||||
const scale = isMobile && mobileScale ? mobileScale : 1;
|
||||
|
||||
// 크기 계산
|
||||
let width: string;
|
||||
let flex: string;
|
||||
|
||||
if (size.width === "fixed") {
|
||||
width = `${(size.fixedWidth || 100) * scale}px`;
|
||||
flex = "0 0 auto";
|
||||
} else if (size.width === "fill") {
|
||||
width = "auto";
|
||||
flex = `${size.flexGrow || 1} ${size.flexShrink || 1} 0`;
|
||||
} else { // hug
|
||||
width = "auto";
|
||||
flex = "0 0 auto";
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
flex,
|
||||
width,
|
||||
minWidth: size.minWidth ? `${size.minWidth * scale}px` : undefined,
|
||||
maxWidth: size.maxWidth ? `${size.maxWidth * scale}px` : undefined,
|
||||
height: size.height === "fixed" ? `${(size.fixedHeight || 48) * scale}px` : "auto",
|
||||
minHeight: size.minHeight ? `${size.minHeight * scale}px` : undefined,
|
||||
maxHeight: size.maxHeight ? `${size.maxHeight * scale}px` : undefined,
|
||||
margin: margin ?
|
||||
`${margin.top}px ${margin.right}px ${margin.bottom}px ${margin.left}px`
|
||||
: undefined,
|
||||
alignSelf: component.alignSelf,
|
||||
}}>
|
||||
<ActualComponent {...component} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 반응형 훅
|
||||
|
||||
```typescript
|
||||
function useResponsiveValue<T>(
|
||||
container: PopContainer,
|
||||
property: keyof PopContainer
|
||||
): T {
|
||||
const windowWidth = useWindowWidth();
|
||||
|
||||
// 기본값
|
||||
let value = container[property] as T;
|
||||
|
||||
// 반응형 규칙 적용 (작은 브레이크포인트 우선)
|
||||
if (container.responsive) {
|
||||
const sortedRules = [...container.responsive].sort((a, b) => b.breakpoint - a.breakpoint);
|
||||
for (const rule of sortedRules) {
|
||||
if (windowWidth <= rule.breakpoint && rule[property] !== undefined) {
|
||||
value = rule[property] as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 단계
|
||||
|
||||
### Phase 1: 데이터 구조 (1-2일)
|
||||
|
||||
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||
|
||||
1. `PopLayoutDataV4` 인터페이스 정의
|
||||
2. `PopContainer` 인터페이스 정의
|
||||
3. `PopComponentDefinitionV4` 인터페이스 정의
|
||||
4. `createEmptyPopLayoutV4()` 함수
|
||||
5. `migrateV3ToV4()` 마이그레이션 함수
|
||||
6. `ensureV4Layout()` 함수
|
||||
7. 타입 가드 함수들
|
||||
|
||||
### Phase 2: 렌더러 (2-3일)
|
||||
|
||||
**파일**: `frontend/components/pop/designer/renderers/PopLayoutRendererV4.tsx`
|
||||
|
||||
1. `renderContainer()` 함수
|
||||
2. `renderComponent()` 함수
|
||||
3. `useResponsiveValue()` 훅
|
||||
4. `useWindowWidth()` 훅
|
||||
5. CSS 스타일 계산 로직
|
||||
6. 반응형 브레이크포인트 처리
|
||||
|
||||
### Phase 3: 디자이너 UI (3-4일)
|
||||
|
||||
**파일**: `frontend/components/pop/designer/PopDesignerV4.tsx`
|
||||
|
||||
1. 캔버스 영역 (드래그 앤 드롭)
|
||||
2. 컴포넌트 팔레트 (기존 + 스택)
|
||||
3. 속성 패널
|
||||
- 크기 제약 편집
|
||||
- 정렬 편집
|
||||
- 여백 편집
|
||||
- 반응형 규칙 편집
|
||||
4. 미리보기 모드 (다양한 화면 크기)
|
||||
5. 컨테이너(스택) 관리
|
||||
- 컨테이너 추가/삭제
|
||||
- 컨테이너 설정 편집
|
||||
- 컴포넌트 이동 (컨테이너 간)
|
||||
|
||||
### Phase 4: 뷰어 통합 (1-2일)
|
||||
|
||||
**파일**: `frontend/app/(pop)/pop/screens/[screenId]/page.tsx`
|
||||
|
||||
1. v4 레이아웃 감지 및 렌더링
|
||||
2. 기존 v3 호환 유지
|
||||
3. 반응형 모드 감지 연동
|
||||
4. 성능 최적화
|
||||
|
||||
### Phase 5: 백엔드 수정 (1일)
|
||||
|
||||
**파일**: `backend-node/src/services/screenManagementService.ts`
|
||||
|
||||
1. `saveLayoutPop` - v4 버전 감지 및 저장
|
||||
2. `getLayoutPop` - v4 버전 반환
|
||||
3. 버전 마이그레이션 로직
|
||||
|
||||
### Phase 6: 테스트 및 마이그레이션 (2-3일)
|
||||
|
||||
1. 단위 테스트
|
||||
2. 통합 테스트
|
||||
3. 기존 v3 레이아웃 마이그레이션 도구
|
||||
4. 크로스 디바이스 테스트
|
||||
|
||||
---
|
||||
|
||||
## 6. 마이그레이션 전략
|
||||
|
||||
### 6.1 v3 → v4 자동 변환
|
||||
|
||||
```typescript
|
||||
function migrateV3ToV4(v3: PopLayoutDataV3): PopLayoutDataV4 {
|
||||
// 태블릿 가로 모드 기준으로 변환
|
||||
const baseLayout = v3.layouts.tablet_landscape;
|
||||
const componentIds = Object.keys(baseLayout.componentPositions);
|
||||
|
||||
// 컴포넌트를 row, col 순으로 정렬
|
||||
const sortedIds = componentIds.sort((a, b) => {
|
||||
const posA = baseLayout.componentPositions[a];
|
||||
const posB = baseLayout.componentPositions[b];
|
||||
if (posA.row !== posB.row) return posA.row - posB.row;
|
||||
return posA.col - posB.col;
|
||||
});
|
||||
|
||||
// 같은 row에 있는 컴포넌트들을 가로 스택으로 그룹화
|
||||
const rowGroups = groupByRow(sortedIds, baseLayout.componentPositions);
|
||||
|
||||
// 루트 컨테이너 (세로 스택)
|
||||
const rootContainer: PopContainer = {
|
||||
id: "root",
|
||||
type: "stack",
|
||||
direction: "vertical",
|
||||
wrap: false,
|
||||
gap: v3.settings.canvasGrid.gap,
|
||||
alignItems: "stretch",
|
||||
justifyContent: "start",
|
||||
children: [],
|
||||
};
|
||||
|
||||
// 각 행을 가로 스택으로 변환
|
||||
for (const [row, ids] of rowGroups) {
|
||||
if (ids.length === 1) {
|
||||
// 단일 컴포넌트면 직접 추가
|
||||
rootContainer.children.push(ids[0]);
|
||||
} else {
|
||||
// 여러 컴포넌트면 가로 스택으로 감싸기
|
||||
const rowStack: PopContainer = {
|
||||
id: `row-${row}`,
|
||||
type: "stack",
|
||||
direction: "horizontal",
|
||||
wrap: true,
|
||||
gap: v3.settings.canvasGrid.gap,
|
||||
alignItems: "center",
|
||||
justifyContent: "start",
|
||||
children: ids,
|
||||
};
|
||||
rootContainer.children.push(rowStack);
|
||||
}
|
||||
}
|
||||
|
||||
// 컴포넌트 정의 변환
|
||||
const components: Record<string, PopComponentDefinitionV4> = {};
|
||||
for (const id of componentIds) {
|
||||
const v3Comp = v3.components[id];
|
||||
const pos = baseLayout.componentPositions[id];
|
||||
|
||||
components[id] = {
|
||||
...v3Comp,
|
||||
size: {
|
||||
// colSpan을 기반으로 크기 모드 결정
|
||||
width: pos.colSpan >= 20 ? "fill" : "fixed",
|
||||
height: "fixed",
|
||||
fixedWidth: pos.colSpan * (1024 / 24), // 대략적인 픽셀 변환
|
||||
fixedHeight: pos.rowSpan * (768 / 24),
|
||||
minWidth: 100,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
version: "pop-4.0",
|
||||
root: rootContainer,
|
||||
components,
|
||||
dataFlow: v3.dataFlow,
|
||||
settings: {
|
||||
touchTargetMin: v3.settings.touchTargetMin,
|
||||
mode: v3.settings.mode,
|
||||
defaultGap: v3.settings.canvasGrid.gap,
|
||||
defaultPadding: 16,
|
||||
breakpoints: {
|
||||
tablet: 768,
|
||||
mobile: 480,
|
||||
},
|
||||
},
|
||||
metadata: v3.metadata,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 하위 호환
|
||||
|
||||
- v3 레이아웃은 계속 지원
|
||||
- 디자이너에서 v3 → v4 업그레이드 버튼 제공
|
||||
- 새로 생성하는 레이아웃은 v4
|
||||
|
||||
---
|
||||
|
||||
## 7. 예상 효과
|
||||
|
||||
### 7.1 사용자 경험
|
||||
|
||||
| 항목 | 기존 (v3) | 새로운 (v4) |
|
||||
|------|-----------|-------------|
|
||||
| 설계 개수 | 4개 | 1개 |
|
||||
| 작업 시간 | 4배 | 1배 |
|
||||
| 반응형 | 수동 | 자동 |
|
||||
| 디바이스 대응 | 각각 설정 | mobileScale |
|
||||
|
||||
### 7.2 개발자 경험
|
||||
|
||||
| 항목 | 기존 (v3) | 새로운 (v4) |
|
||||
|------|-----------|-------------|
|
||||
| 렌더링 | CSS Grid | Flexbox |
|
||||
| 위치 계산 | col/row | 자동 |
|
||||
| 반응형 로직 | 4모드 분기 | 브레이크포인트 |
|
||||
| 유지보수 | 복잡 | 단순 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 일정 (예상)
|
||||
|
||||
| Phase | 내용 | 기간 |
|
||||
|-------|------|------|
|
||||
| 1 | 데이터 구조 | 1-2일 |
|
||||
| 2 | 렌더러 | 2-3일 |
|
||||
| 3 | 디자이너 UI | 3-4일 |
|
||||
| 4 | 뷰어 통합 | 1-2일 |
|
||||
| 5 | 백엔드 수정 | 1일 |
|
||||
| 6 | 테스트/마이그레이션 | 2-3일 |
|
||||
| **총계** | | **10-15일** |
|
||||
|
||||
---
|
||||
|
||||
## 9. 리스크 및 대응
|
||||
|
||||
### 9.1 기존 레이아웃 호환성
|
||||
|
||||
- **리스크**: v3 → v4 자동 변환이 완벽하지 않을 수 있음
|
||||
- **대응**:
|
||||
- 마이그레이션 미리보기 기능
|
||||
- 수동 조정 도구 제공
|
||||
- v3 유지 옵션
|
||||
|
||||
### 9.2 학습 곡선
|
||||
|
||||
- **리스크**: 제약조건 개념이 익숙하지 않을 수 있음
|
||||
- **대응**:
|
||||
- 프리셋 제공 (예: "화면 전체 채우기", "고정 크기")
|
||||
- 툴팁/도움말
|
||||
- 예제 템플릿
|
||||
|
||||
### 9.3 성능
|
||||
|
||||
- **리스크**: Flexbox 중첩으로 렌더링 성능 저하
|
||||
- **대응**:
|
||||
- 컨테이너 중첩 깊이 제한 (최대 3-4)
|
||||
- React.memo 활용
|
||||
- 가상화 (리스트 컴포넌트)
|
||||
|
||||
---
|
||||
|
||||
## 10. 결론
|
||||
|
||||
v4.0 제약조건 기반 시스템은 업계 표준(Figma, Flutter, SwiftUI)을 따르며, 사용자의 작업량을 75% 줄이고 자동 반응형을 제공합니다.
|
||||
|
||||
구현 후 POP 디자이너는:
|
||||
- **1개 레이아웃**만 설계
|
||||
- **모든 화면 크기**에 자동 적응
|
||||
- **모바일 특화 설정** (mobileScale)으로 세밀한 제어 가능
|
||||
|
||||
---
|
||||
|
||||
## 11. 추가 설정 (2026-02-03 업데이트)
|
||||
|
||||
### 11.1 확장된 전역 설정
|
||||
|
||||
```typescript
|
||||
interface PopGlobalSettingsV4 {
|
||||
// 기존
|
||||
touchTargetMin: number; // 48 (normal) / 60 (industrial)
|
||||
mode: "normal" | "industrial";
|
||||
defaultGap: number;
|
||||
defaultPadding: number;
|
||||
breakpoints: {
|
||||
tablet: number; // 768
|
||||
mobile: number; // 480
|
||||
};
|
||||
|
||||
// 신규 추가
|
||||
environment: "indoor" | "outdoor"; // 야외면 대비 높임
|
||||
|
||||
typography: {
|
||||
body: { min: number; max: number }; // 14-18px
|
||||
heading: { min: number; max: number }; // 18-28px
|
||||
caption: { min: number; max: number }; // 12-14px
|
||||
};
|
||||
|
||||
contrast: "normal" | "high"; // outdoor면 자동 high
|
||||
}
|
||||
```
|
||||
|
||||
### 11.2 컴포넌트 기본값 프리셋
|
||||
|
||||
컴포넌트 추가 시 자동 적용되는 안전한 기본값:
|
||||
|
||||
```typescript
|
||||
const COMPONENT_DEFAULTS = {
|
||||
"pop-button": {
|
||||
minWidth: 80,
|
||||
minHeight: 48,
|
||||
height: "fixed",
|
||||
fixedHeight: 48,
|
||||
},
|
||||
"pop-field": {
|
||||
minWidth: 120,
|
||||
minHeight: 40,
|
||||
height: "fixed",
|
||||
fixedHeight: 48,
|
||||
},
|
||||
"pop-list": {
|
||||
minHeight: 200,
|
||||
itemHeight: 48,
|
||||
},
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 11.3 리스트 반응형 컬럼
|
||||
|
||||
```typescript
|
||||
interface PopListConfig {
|
||||
// 기존
|
||||
listType: PopListType;
|
||||
displayColumns?: string[];
|
||||
|
||||
// 신규 추가
|
||||
responsiveColumns?: {
|
||||
tablet: string[]; // 전체 컬럼
|
||||
mobile: string[]; // 주요 컬럼만
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 11.4 라벨 배치 자동화
|
||||
|
||||
```typescript
|
||||
interface PopContainer {
|
||||
// 기존
|
||||
direction: "horizontal" | "vertical";
|
||||
|
||||
// 신규 추가
|
||||
labelPlacement?: "auto" | "above" | "beside";
|
||||
// auto: 모바일 세로=위, 태블릿 가로=옆
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 관련 문서
|
||||
|
||||
- [v4 핵심 규칙 가이드](./V4_CORE_RULES.md) - **3가지 핵심 규칙 (필독)**
|
||||
- [반응형 디자인 가이드](./RESPONSIVE_DESIGN_GUIDE.md)
|
||||
- [컴포넌트 로드맵](./COMPONENT_ROADMAP.md)
|
||||
- [크기 프리셋 가이드](./SIZE_PRESETS.md)
|
||||
- [컴포넌트 상세 스펙](./components-spec.md)
|
||||
|
||||
---
|
||||
|
||||
## 13. 현재 상태 (2026-02-03)
|
||||
|
||||
**구현 대기**: 컴포넌트가 아직 없어서 레이아웃 시스템보다 컴포넌트 개발이 선행되어야 함.
|
||||
|
||||
**권장 진행 순서**:
|
||||
1. 기초 컴포넌트 개발 (PopButton, PopInput 등)
|
||||
2. 조합 컴포넌트 개발 (PopFormField, PopCard 등)
|
||||
3. 복합 컴포넌트 개발 (PopDataTable, PopCardList 등)
|
||||
4. v4 레이아웃 시스템 구현
|
||||
5. 디자이너 UI 개발
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-02-03*
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
# VEXPLOR (WACE 솔루션) 프로젝트 아키텍처
|
||||
|
||||
> AI 에이전트 안내: Quick Reference 먼저 확인 후 필요한 섹션만 참조
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 기술 스택 요약
|
||||
|
||||
| 영역 | 기술 |
|
||||
|------|------|
|
||||
| Frontend | Next.js 14, TypeScript, shadcn/ui, Tailwind CSS |
|
||||
| Backend | Node.js + Express (주력), Java Spring (레거시) |
|
||||
| Database | PostgreSQL (173개 테이블) |
|
||||
| 핵심 기능 | 노코드 화면 빌더, 멀티테넌시, 워크플로우 |
|
||||
|
||||
### 디렉토리 맵
|
||||
|
||||
```
|
||||
ERP-node/
|
||||
├── frontend/ # Next.js
|
||||
│ ├── app/ # 라우팅 (main, auth, pop)
|
||||
│ ├── components/ # UI 컴포넌트 (281개+)
|
||||
│ └── lib/ # API, 레지스트리 (463개)
|
||||
├── backend-node/ # Node.js 백엔드
|
||||
│ └── src/
|
||||
│ ├── controllers/ # 68개
|
||||
│ ├── services/ # 78개
|
||||
│ └── routes/ # 47개
|
||||
├── db/ # 마이그레이션
|
||||
└── docs/ # 문서
|
||||
```
|
||||
|
||||
### 핵심 테이블
|
||||
|
||||
| 분류 | 테이블 |
|
||||
|------|--------|
|
||||
| 화면 | screen_definitions, screen_layouts_v2, screen_layouts_pop |
|
||||
| 메뉴 | menu_info, authority_master |
|
||||
| 사용자 | user_info, company_mng |
|
||||
| 플로우 | flow_definition, flow_step |
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
### 1.1 제품 소개
|
||||
- **WACE 솔루션**: PLM(Product Lifecycle Management) + 노코드 화면 빌더
|
||||
- **멀티테넌시**: company_code 기반 회사별 데이터 격리
|
||||
- **마이그레이션**: JSP에서 Next.js로 완전 전환
|
||||
|
||||
### 1.2 핵심 기능
|
||||
1. **Screen Designer**: 드래그앤드롭 화면 구성
|
||||
2. **워크플로우**: 플로우 기반 업무 자동화
|
||||
3. **배치 시스템**: 스케줄 기반 작업 자동화
|
||||
4. **외부 연동**: DB, REST API 통합
|
||||
5. **리포트**: 동적 보고서 생성
|
||||
6. **다국어**: i18n 지원
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend 구조
|
||||
|
||||
### 2.1 라우트 그룹
|
||||
|
||||
```
|
||||
app/
|
||||
├── (main)/ # 메인 레이아웃
|
||||
│ ├── admin/ # 관리자 기능
|
||||
│ │ ├── screenMng/ # 화면 관리
|
||||
│ │ ├── systemMng/ # 시스템 관리
|
||||
│ │ ├── userMng/ # 사용자 관리
|
||||
│ │ └── automaticMng/ # 자동화 관리
|
||||
│ ├── screens/[screenId]/ # 동적 화면 뷰어
|
||||
│ └── dashboard/[id]/ # 대시보드 뷰어
|
||||
├── (auth)/ # 인증
|
||||
│ └── login/
|
||||
└── (pop)/ # POP 전용
|
||||
└── pop/screens/[screenId]/
|
||||
```
|
||||
|
||||
### 2.2 주요 컴포넌트
|
||||
|
||||
| 폴더 | 파일 수 | 역할 |
|
||||
|------|---------|------|
|
||||
| screen/ | 70+ | 화면 디자이너, 위젯 |
|
||||
| admin/ | 137 | 테이블, 메뉴, 코드 관리 |
|
||||
| dataflow/ | 101 | 노드 기반 플로우 에디터 |
|
||||
| dashboard/ | 32 | 대시보드 빌더 |
|
||||
| v2/ | 20+ | V2 컴포넌트 시스템 |
|
||||
| pop/ | 26 | POP 전용 컴포넌트 |
|
||||
|
||||
### 2.3 라이브러리 (lib/)
|
||||
|
||||
```
|
||||
lib/
|
||||
├── api/ # API 클라이언트 (50개+)
|
||||
│ ├── screen.ts # 화면 API
|
||||
│ ├── menu.ts # 메뉴 API
|
||||
│ └── flow.ts # 플로우 API
|
||||
├── registry/ # 컴포넌트 레지스트리 (463개)
|
||||
│ ├── DynamicComponentRenderer.tsx
|
||||
│ └── components/
|
||||
├── v2-core/ # Zod 기반 타입 시스템
|
||||
└── utils/ # 유틸리티 (30개+)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend 구조
|
||||
|
||||
### 3.1 디렉토리
|
||||
|
||||
```
|
||||
backend-node/src/
|
||||
├── controllers/ # 68개 컨트롤러
|
||||
├── services/ # 78개 서비스
|
||||
├── routes/ # 47개 라우트
|
||||
├── middleware/ # 인증, 에러 처리
|
||||
├── database/ # DB 연결
|
||||
├── types/ # 26개 타입 정의
|
||||
└── utils/ # 16개 유틸
|
||||
```
|
||||
|
||||
### 3.2 주요 서비스
|
||||
|
||||
| 영역 | 서비스 |
|
||||
|------|--------|
|
||||
| 화면 | screenManagementService, layoutService |
|
||||
| 데이터 | dataService, tableManagementService |
|
||||
| 플로우 | flowDefinitionService, flowExecutionService |
|
||||
| 배치 | batchService, batchSchedulerService |
|
||||
| 외부연동 | externalDbConnectionService, externalCallService |
|
||||
|
||||
### 3.3 API 엔드포인트
|
||||
|
||||
```
|
||||
# 화면 관리
|
||||
GET /api/screen-management/screens
|
||||
GET /api/screen-management/screen/:id
|
||||
POST /api/screen-management/screen
|
||||
PUT /api/screen-management/screen/:id
|
||||
GET /api/screen-management/layout-v2/:screenId
|
||||
POST /api/screen-management/layout-v2/:screenId
|
||||
GET /api/screen-management/layout-pop/:screenId
|
||||
POST /api/screen-management/layout-pop/:screenId
|
||||
|
||||
# 데이터 CRUD
|
||||
GET /api/data/:tableName
|
||||
POST /api/data/:tableName
|
||||
PUT /api/data/:tableName/:id
|
||||
DELETE /api/data/:tableName/:id
|
||||
|
||||
# 인증
|
||||
POST /api/auth/login
|
||||
POST /api/auth/logout
|
||||
GET /api/auth/me
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Database 구조
|
||||
|
||||
### 4.1 테이블 분류 (173개)
|
||||
|
||||
| 분류 | 개수 | 주요 테이블 |
|
||||
|------|------|-------------|
|
||||
| 화면/레이아웃 | 15 | screen_definitions, screen_layouts_v2, screen_layouts_pop |
|
||||
| 메뉴/권한 | 7 | menu_info, authority_master, rel_menu_auth |
|
||||
| 사용자/회사 | 6 | user_info, company_mng, dept_info |
|
||||
| 테이블/컬럼 | 8 | table_type_columns, column_labels |
|
||||
| 다국어 | 4 | multi_lang_key_master, multi_lang_text |
|
||||
| 플로우/배치 | 12 | flow_definition, flow_step, batch_configs |
|
||||
| 외부연동 | 4 | external_db_connections, external_call_configs |
|
||||
| 리포트 | 5 | report_master, report_layout, report_query |
|
||||
| 대시보드 | 2 | dashboards, dashboard_elements |
|
||||
| 컴포넌트 | 6 | component_standards, web_type_standards |
|
||||
| 비즈니스 | 100+ | item_info, sales_order_mng, inventory_stock |
|
||||
|
||||
### 4.2 화면 관련 테이블 상세
|
||||
|
||||
```sql
|
||||
-- 화면 정의
|
||||
screen_definitions: screen_id, screen_name, table_name, company_code
|
||||
|
||||
-- 데스크톱 레이아웃 (V2, 현재 사용)
|
||||
screen_layouts_v2: id, screen_id, components(JSONB), grid_settings
|
||||
|
||||
-- POP 레이아웃
|
||||
screen_layouts_pop: id, screen_id, components(JSONB), grid_settings
|
||||
|
||||
-- 화면 그룹
|
||||
screen_groups: group_id, group_name, company_code
|
||||
screen_group_screens: id, group_id, screen_id
|
||||
```
|
||||
|
||||
### 4.3 멀티테넌시
|
||||
|
||||
```sql
|
||||
-- 모든 테이블에 company_code 필수
|
||||
ALTER TABLE example_table ADD COLUMN company_code VARCHAR(20) NOT NULL;
|
||||
|
||||
-- 모든 쿼리에 company_code 필터링 필수
|
||||
SELECT * FROM example_table WHERE company_code = $1;
|
||||
|
||||
-- 예외: company_mng (회사 마스터 테이블)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 핵심 기능 상세
|
||||
|
||||
### 5.1 노코드 Screen Designer
|
||||
|
||||
**아키텍처**:
|
||||
```
|
||||
screen_definitions (화면 정의)
|
||||
↓
|
||||
screen_layouts_v2 (레이아웃, JSONB)
|
||||
↓
|
||||
DynamicComponentRenderer (동적 렌더링)
|
||||
↓
|
||||
registry/components (컴포넌트 라이브러리)
|
||||
```
|
||||
|
||||
**컴포넌트 레지스트리**:
|
||||
- V2 컴포넌트: Input, Select, Table, Button 등
|
||||
- 위젯: FlowWidget, CategoryWidget 등
|
||||
- 레이아웃: SplitPanel, TabPanel 등
|
||||
|
||||
### 5.2 워크플로우 (Flow)
|
||||
|
||||
**테이블 구조**:
|
||||
```
|
||||
flow_definition (플로우 정의)
|
||||
↓
|
||||
flow_step (단계 정의)
|
||||
↓
|
||||
flow_step_connection (단계 연결)
|
||||
↓
|
||||
flow_data_status (데이터 상태 추적)
|
||||
```
|
||||
|
||||
### 5.3 POP 시스템
|
||||
|
||||
**별도 레이아웃 테이블**:
|
||||
- `screen_layouts_pop`: POP 전용 레이아웃
|
||||
- 모바일/태블릿 반응형 지원
|
||||
- 제조 현장 최적화 컴포넌트
|
||||
|
||||
---
|
||||
|
||||
## 6. 개발 환경
|
||||
|
||||
### 6.1 로컬 개발
|
||||
|
||||
```bash
|
||||
# Docker 실행
|
||||
docker-compose -f docker-compose.win.yml up -d
|
||||
|
||||
# 프론트엔드: http://localhost:9771
|
||||
# 백엔드: http://localhost:8080
|
||||
```
|
||||
|
||||
### 6.2 데이터베이스
|
||||
|
||||
```
|
||||
Host: 39.117.244.52
|
||||
Port: 11132
|
||||
Database: plm
|
||||
Username: postgres
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 관련 문서
|
||||
|
||||
- [POPUPDATE.md](../POPUPDATE.md): POP 개발 기록
|
||||
- [docs/pop/components-spec.md](pop/components-spec.md): POP 컴포넌트 설계
|
||||
- [.cursorrules](../.cursorrules): 개발 가이드라인
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-01-29*
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
# POP 반응형 디자인 가이드
|
||||
|
||||
## 쉬운 요약
|
||||
|
||||
### 핵심 원칙: 3가지만 기억하세요
|
||||
|
||||
```
|
||||
1. 누르는 것 → 크기 고정 (최소 48px)
|
||||
2. 읽는 것 → 범위 안에서 자동 조절
|
||||
3. 담는 것 → 화면에 맞춰 늘어남
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 터치 요소 (고정 크기)
|
||||
|
||||
손가락 크기는 화면이 커져도 변하지 않습니다.
|
||||
|
||||
| 요소 | 일반 | 산업현장(장갑) |
|
||||
|------|-----|--------------|
|
||||
| 버튼 | 48px | 60px |
|
||||
| 아이콘 (누르는 용) | 48px | 60px |
|
||||
| 체크박스 | 24px (터치영역 48px) | 24px (터치영역 60px) |
|
||||
| 리스트 한 줄 높이 | 48px | 56px |
|
||||
| 입력창 높이 | 40px | 48px |
|
||||
|
||||
---
|
||||
|
||||
## 2. 텍스트 (범위 조절)
|
||||
|
||||
화면 크기에 따라 자동으로 커지거나 작아집니다.
|
||||
|
||||
| 용도 | 최소 | 최대 |
|
||||
|------|-----|-----|
|
||||
| 본문 | 14px | 18px |
|
||||
| 제목 | 18px | 28px |
|
||||
| 설명 | 12px | 14px |
|
||||
|
||||
**CSS 예시**:
|
||||
```css
|
||||
font-size: clamp(14px, 1.5vw, 18px);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 레이아웃 (비율 기반)
|
||||
|
||||
컨테이너는 화면에 맞춰 늘어납니다.
|
||||
|
||||
| 요소 | 방식 | 예시 |
|
||||
|------|-----|-----|
|
||||
| 컨테이너 | 100% | 화면 전체 채움 |
|
||||
| 카드 2열 | 48% + 48% | 화면 반씩 |
|
||||
| 입력창 너비 | fill | 부모 채움 |
|
||||
| 여백 | 8/16/24px | 화면 크기별 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 환경별 설정
|
||||
|
||||
### 일반 (실내)
|
||||
- 터치: 48px
|
||||
- 대비: 4.5:1
|
||||
- 폰트: 14-18px
|
||||
|
||||
### 산업현장 (야외/장갑)
|
||||
- 터치: 60px (+25%)
|
||||
- 대비: 7:1 이상
|
||||
- 폰트: 18-22px (+25%)
|
||||
|
||||
---
|
||||
|
||||
## 5. 리스트/테이블 반응형
|
||||
|
||||
화면이 좁아지면 컬럼을 줄입니다.
|
||||
|
||||
```
|
||||
태블릿 (넓음) 모바일 (좁음)
|
||||
┌──────┬──────┬──────┬──────┐ ┌──────┬──────┐
|
||||
│품번 │품명 │수량 │상태 │ │품번 │수량 │
|
||||
├──────┼──────┼──────┼──────┤ ├──────┼──────┤
|
||||
│A001 │나사 │100 │완료 │ │A001 │100 │
|
||||
└──────┴──────┴──────┴──────┘ └──────┴──────┘
|
||||
↳ 터치하면 상세보기
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 폼 라벨 배치
|
||||
|
||||
| 화면 | 라벨 위치 | 이유 |
|
||||
|------|----------|-----|
|
||||
| 모바일 세로 | 위 | 입력창 너비 확보 |
|
||||
| 태블릿 가로 | 옆 | 공간 여유 |
|
||||
|
||||
```
|
||||
모바일 태블릿
|
||||
┌─────────────────┐ ┌─────────────────────────┐
|
||||
│ 이름 │ │ 이름: [입력____________] │
|
||||
│ [입력__________]│ └─────────────────────────┘
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 그림으로 보는 반응형
|
||||
|
||||
```
|
||||
8인치 태블릿 12인치 태블릿
|
||||
┌─────────────────┐ ┌───────────────────────┐
|
||||
│ [버튼 48px] │ │ [버튼 48px] │ ← 버튼 크기 동일!
|
||||
│ │ │ │
|
||||
│ ┌─────────────┐ │ │ ┌─────────────────┐ │
|
||||
│ │ 입력창 │ │ │ │ 입력창 │ │ ← 너비만 늘어남
|
||||
│ └─────────────┘ │ │ └─────────────────┘ │
|
||||
│ │ │ │
|
||||
│ 글자 14px │ │ 글자 18px │ ← 글자만 커짐
|
||||
└─────────────────┘ └───────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 색상 대비 (야외용)
|
||||
|
||||
| 환경 | 최소 대비 | 권장 |
|
||||
|------|----------|-----|
|
||||
| 실내 | 4.5:1 | 7:1 |
|
||||
| 야외 | 7:1 | 10:1+ |
|
||||
|
||||
**좋은 조합**:
|
||||
- 흰 배경 + 검정 글자
|
||||
- 검정 배경 + 흰 글자
|
||||
- 노랑 경고 + 검정 글자
|
||||
|
||||
**피해야 할 조합**:
|
||||
- 연한 회색 + 밝은 회색
|
||||
- 빨강 + 녹색 (색맹 고려)
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
### 컴포넌트 만들 때 확인
|
||||
|
||||
- [ ] 버튼/터치 요소 최소 48px인가?
|
||||
- [ ] 폰트에 clamp() 적용했나?
|
||||
- [ ] 색상 대비 4.5:1 이상인가?
|
||||
- [ ] 모바일에서 라벨이 위에 있나?
|
||||
- [ ] 리스트가 좁은 화면에서 컬럼 줄어드나?
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-02-03*
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
# POP 크기 프리셋 가이드
|
||||
|
||||
## 컴포넌트별 기본 크기
|
||||
|
||||
컴포넌트를 만들면 자동으로 적용되는 크기입니다.
|
||||
|
||||
---
|
||||
|
||||
## 버튼 (PopButton)
|
||||
|
||||
| 사이즈 | 높이 | 최소 너비 | 폰트 | 용도 |
|
||||
|-------|------|----------|------|-----|
|
||||
| sm | 32px | 60px | 12px | 보조 버튼 |
|
||||
| md | 40px | 80px | 14px | 일반 버튼 |
|
||||
| **lg** | **48px** | **100px** | **16px** | **POP 기본** |
|
||||
| xl | 56px | 120px | 18px | 주요 액션 |
|
||||
| industrial | 60px | 140px | 20px | 장갑 착용 |
|
||||
|
||||
```typescript
|
||||
// POP에서는 lg가 기본
|
||||
<PopButton size="lg">확인</PopButton>
|
||||
|
||||
// 산업현장
|
||||
<PopButton size="industrial">작업 완료</PopButton>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 입력창 (PopInput)
|
||||
|
||||
| 사이즈 | 높이 | 폰트 | 용도 |
|
||||
|-------|------|------|-----|
|
||||
| md | 40px | 14px | 일반 |
|
||||
| **lg** | **48px** | **16px** | **POP 기본** |
|
||||
| xl | 56px | 18px | 강조 입력 |
|
||||
|
||||
```typescript
|
||||
// 입력창 너비는 항상 부모 채움 (fill)
|
||||
<PopInput size="lg" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 리스트 행 (PopListItem)
|
||||
|
||||
| 사이즈 | 높이 | 폰트 | 용도 |
|
||||
|-------|------|------|-----|
|
||||
| compact | 40px | 14px | 많은 데이터 |
|
||||
| **normal** | **48px** | **16px** | **POP 기본** |
|
||||
| spacious | 56px | 18px | 여유로운 |
|
||||
| industrial | 64px | 20px | 장갑 착용 |
|
||||
|
||||
```typescript
|
||||
<PopListItem size="normal">
|
||||
<span>작업지시 #1234</span>
|
||||
</PopListItem>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 아이콘 (PopIcon)
|
||||
|
||||
| 사이즈 | 크기 | 터치 영역 | 용도 |
|
||||
|-------|-----|----------|-----|
|
||||
| sm | 16px | 32px | 뱃지 안 |
|
||||
| md | 20px | 40px | 텍스트 옆 |
|
||||
| **lg** | **24px** | **48px** | **POP 기본** |
|
||||
| xl | 32px | 56px | 강조 |
|
||||
|
||||
```typescript
|
||||
// 아이콘만 있는 버튼
|
||||
<PopButton icon="check" size="lg" />
|
||||
|
||||
// 텍스트 + 아이콘
|
||||
<PopButton icon="save" size="lg">저장</PopButton>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 카드 (PopCard)
|
||||
|
||||
| 요소 | 크기 |
|
||||
|------|-----|
|
||||
| 패딩 | 16px |
|
||||
| 제목 폰트 | 18px (heading) |
|
||||
| 본문 폰트 | 16px (body) |
|
||||
| 모서리 | 8px |
|
||||
| 최소 높이 | 100px |
|
||||
|
||||
```typescript
|
||||
<PopCard>
|
||||
<PopCard.Header>작업지시</PopCard.Header>
|
||||
<PopCard.Body>내용</PopCard.Body>
|
||||
</PopCard>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 숫자패드 (PopNumberPad)
|
||||
|
||||
| 요소 | 크기 |
|
||||
|------|-----|
|
||||
| 버튼 크기 | 60px x 60px |
|
||||
| 버튼 간격 | 8px |
|
||||
| 전체 너비 | 240px |
|
||||
| 폰트 | 24px |
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ [ 123 ] │ ← 디스플레이 48px
|
||||
├─────┬─────┬─────────┤
|
||||
│ 7 │ 8 │ 9 │ ← │ ← 각 버튼 60x60
|
||||
├─────┼─────┼─────────┤
|
||||
│ 4 │ 5 │ 6 │ C │
|
||||
├─────┼─────┼─────────┤
|
||||
│ 1 │ 2 │ 3 │ │
|
||||
├─────┼─────┼─────│ OK│
|
||||
│ 0 │ . │ +- │ │
|
||||
└─────┴─────┴─────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 상태 표시 (PopStatusBox)
|
||||
|
||||
| 사이즈 | 너비 | 높이 | 아이콘 | 용도 |
|
||||
|-------|-----|------|-------|-----|
|
||||
| sm | 80px | 60px | 24px | 여러 개 나열 |
|
||||
| **md** | **120px** | **80px** | **32px** | **POP 기본** |
|
||||
| lg | 160px | 100px | 40px | 강조 |
|
||||
|
||||
```typescript
|
||||
<PopStatusBox
|
||||
label="설비 상태"
|
||||
value="가동중"
|
||||
status="success"
|
||||
size="md"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## KPI 게이지 (PopKpiGauge)
|
||||
|
||||
| 사이즈 | 너비 | 높이 | 용도 |
|
||||
|-------|-----|------|-----|
|
||||
| sm | 120px | 120px | 여러 개 나열 |
|
||||
| **md** | **180px** | **180px** | **POP 기본** |
|
||||
| lg | 240px | 240px | 강조 |
|
||||
|
||||
---
|
||||
|
||||
## 간격 (Gap/Padding)
|
||||
|
||||
| 이름 | 값 | 용도 |
|
||||
|------|---|-----|
|
||||
| xs | 4px | 아이콘-텍스트 |
|
||||
| sm | 8px | 요소 내부 |
|
||||
| **md** | **16px** | **컴포넌트 간** |
|
||||
| lg | 24px | 섹션 간 |
|
||||
| xl | 32px | 영역 구분 |
|
||||
|
||||
---
|
||||
|
||||
## 반응형 조절
|
||||
|
||||
화면 크기에 따라 자동 조절되는 값들:
|
||||
|
||||
| 요소 | 8인치 태블릿 | 12인치 태블릿 |
|
||||
|------|------------|--------------|
|
||||
| 본문 폰트 | 14px | 18px |
|
||||
| 제목 폰트 | 18px | 28px |
|
||||
| 컨테이너 패딩 | 12px | 24px |
|
||||
| 카드 간격 | 12px | 16px |
|
||||
|
||||
**고정되는 값들 (변하지 않음)**:
|
||||
- 버튼 높이: 48px
|
||||
- 입력창 높이: 48px
|
||||
- 리스트 행 높이: 48px
|
||||
- 터치 최소 영역: 48px
|
||||
|
||||
---
|
||||
|
||||
## 적용 예시
|
||||
|
||||
```typescript
|
||||
// 컴포넌트 내부에서 자동 적용
|
||||
function PopButton({ size = "lg", ...props }) {
|
||||
const sizeStyles = {
|
||||
sm: { height: 32, minWidth: 60, fontSize: 12 },
|
||||
md: { height: 40, minWidth: 80, fontSize: 14 },
|
||||
lg: { height: 48, minWidth: 100, fontSize: 16 }, // POP 기본
|
||||
xl: { height: 56, minWidth: 120, fontSize: 18 },
|
||||
industrial: { height: 60, minWidth: 140, fontSize: 20 },
|
||||
};
|
||||
|
||||
return (
|
||||
<button style={sizeStyles[size]} {...props} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-02-03*
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
# POP 저장/조회 규칙
|
||||
|
||||
**AI가 POP 관련 저장/조회 요청을 처리할 때 참고하는 규칙**
|
||||
|
||||
---
|
||||
|
||||
## 1. 저장 요청 처리
|
||||
|
||||
사용자가 저장/기록/정리/업데이트 등을 요청하면:
|
||||
|
||||
### 1.1 파일 관련
|
||||
|
||||
| 요청 유형 | 처리 방법 |
|
||||
|----------|----------|
|
||||
| 새 파일 추가됨 | `FILES.md`에 파일 정보 추가 |
|
||||
| 구조 변경됨 | `ARCHITECTURE.md` 업데이트 |
|
||||
| 작업 완료 | `CHANGELOG.md`에 기록 |
|
||||
| 중요 결정 | `decisions/` 폴더에 ADR 추가 |
|
||||
|
||||
### 1.2 rangraph 동기화
|
||||
|
||||
| 요청 유형 | rangraph 처리 |
|
||||
|----------|--------------|
|
||||
| 중요 결정 | `save_decision` 호출 |
|
||||
| 교훈/규칙 | `save_lesson` 호출 |
|
||||
| 새 키워드 | `add_keyword` 호출 |
|
||||
| 작업 흐름 | `workflow_submit` 호출 |
|
||||
|
||||
### 1.3 예시
|
||||
|
||||
```
|
||||
사용자: "오늘 작업 정리해줘"
|
||||
AI:
|
||||
1. CHANGELOG.md에 오늘 날짜로 Added/Changed/Fixed 기록
|
||||
2. rangraph save_decision 또는 save_lesson 호출
|
||||
3. 필요시 FILES.md, ARCHITECTURE.md 업데이트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 조회 요청 처리
|
||||
|
||||
사용자가 조회/검색/찾기 등을 요청하면:
|
||||
|
||||
### 2.1 popdocs 우선순위
|
||||
|
||||
| 필요한 정보 | 참조 문서 | 토큰 비용 |
|
||||
|------------|----------|----------|
|
||||
| 빠른 참조 | `README.md` | 낮음 (151줄) |
|
||||
| 전체 구조 | `ARCHITECTURE.md` | 중간 (530줄) |
|
||||
| 파일 위치 | `FILES.md` | 높음 (900줄) |
|
||||
| 기술 스펙 | `SPEC.md` | 중간 |
|
||||
| 컴포넌트 | `components-spec.md` | 중간 |
|
||||
| 진행 상황 | `PLAN.md` | 낮음 |
|
||||
| 변경 이력 | `CHANGELOG.md` | 낮음 |
|
||||
|
||||
### 2.2 조회 전략
|
||||
|
||||
```
|
||||
1단계: rangraph search_memory로 빠르게 확인
|
||||
2단계: 관련 popdocs 문서 참조
|
||||
3단계: 필요시 실제 소스 파일 Read
|
||||
```
|
||||
|
||||
### 2.3 예시
|
||||
|
||||
```
|
||||
사용자: "v4 렌더러 어디있어?"
|
||||
AI:
|
||||
1. rangraph search_memory "v4 렌더러" (캐시된 정보)
|
||||
2. FILES.md에서 확인: PopFlexRenderer.tsx (498줄)
|
||||
3. 필요시 실제 파일 Read
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 토큰 효율화 전략
|
||||
|
||||
### 3.1 문서 읽기 우선순위
|
||||
|
||||
```
|
||||
최소 토큰: README.md (빠른 참조)
|
||||
↓
|
||||
중간 토큰: ARCHITECTURE.md (구조 이해)
|
||||
↓
|
||||
필요시: FILES.md (파일 상세)
|
||||
↓
|
||||
마지막: 실제 소스 파일
|
||||
```
|
||||
|
||||
### 3.2 효율적 패턴
|
||||
|
||||
| 상황 | 효율적 방법 | 비효율적 방법 |
|
||||
|------|------------|--------------|
|
||||
| 파일 위치 찾기 | FILES.md 검색 | 전체 폴더 탐색 |
|
||||
| 구조 이해 | ARCHITECTURE.md | 모든 파일 읽기 |
|
||||
| 빠른 확인 | rangraph 검색 | 문서 전체 읽기 |
|
||||
| 특정 코드 | FILES.md → Read | Glob + 전체 Read |
|
||||
|
||||
### 3.3 캐싱 활용
|
||||
|
||||
rangraph에 저장된 정보:
|
||||
- POP 문서 구조 (save_decision)
|
||||
- 저장/조회 규칙 (save_lesson)
|
||||
- 핵심 파일 위치 (save_lesson)
|
||||
- 키워드: popdocs, ARCHITECTURE.md, FILES.md
|
||||
|
||||
---
|
||||
|
||||
## 4. 문서별 용도
|
||||
|
||||
| 문서 | 읽을 때 | 수정할 때 |
|
||||
|------|--------|----------|
|
||||
| README.md | 빠른 참조 필요 시 | 문서 구조 변경 시 |
|
||||
| ARCHITECTURE.md | 구조 이해 필요 시 | 폴더/모듈 변경 시 |
|
||||
| FILES.md | 파일 찾을 때 | 파일 추가/삭제 시 |
|
||||
| SPEC.md | 기술 스펙 확인 시 | 스펙 변경 시 |
|
||||
| PLAN.md | 진행 상황 확인 시 | 계획 변경 시 |
|
||||
| CHANGELOG.md | 이력 확인 시 | 작업 완료 시 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 실제 처리 예시
|
||||
|
||||
### 5.1 저장 요청
|
||||
|
||||
```
|
||||
사용자: "PopButton 컴포넌트 만들었어"
|
||||
|
||||
AI 처리:
|
||||
1. FILES.md에 파일 정보 추가
|
||||
2. CHANGELOG.md에 Added 기록
|
||||
3. components-spec.md에 스펙 추가 (필요시)
|
||||
4. rangraph save_decision 호출
|
||||
```
|
||||
|
||||
### 5.2 조회 요청
|
||||
|
||||
```
|
||||
사용자: "v4 캔버스 어떻게 동작해?"
|
||||
|
||||
AI 처리:
|
||||
1. rangraph search_memory "v4 캔버스"
|
||||
2. FILES.md에서 PopCanvasV4.tsx 확인
|
||||
3. ARCHITECTURE.md에서 캔버스 섹션 확인
|
||||
4. 필요시 PopCanvasV4.tsx 직접 Read
|
||||
```
|
||||
|
||||
### 5.3 업데이트 요청
|
||||
|
||||
```
|
||||
사용자: "PLAN.md 업데이트해줘"
|
||||
|
||||
AI 처리:
|
||||
1. 현재 PLAN.md 읽기
|
||||
2. 완료된 항목 체크, 새 항목 추가
|
||||
3. rangraph에 진행 상황 저장 (필요시)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 키워드 체계
|
||||
|
||||
rangraph 키워드 카테고리:
|
||||
|
||||
| 카테고리 | 키워드 예시 |
|
||||
|----------|-----------|
|
||||
| pop | popdocs, ARCHITECTURE.md, FILES.md |
|
||||
| v4 | PopFlexRenderer, PopCanvasV4, 제약조건 |
|
||||
| designer | PopDesigner, PopPanel |
|
||||
|
||||
---
|
||||
|
||||
## 7. 이 문서의 용도
|
||||
|
||||
- AI가 POP 관련 요청을 받으면 이 규칙을 참고
|
||||
- 저장 시: popdocs 문서 + rangraph 동기화
|
||||
- 조회 시: 토큰 효율적인 순서로 확인
|
||||
- 사용자가 규칙 변경을 요청하면 이 문서 수정
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-02-04*
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
# POP v4 핵심 규칙 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
v4에서는 **"위치"를 설정하는 게 아니라 "규칙"을 설정**합니다.
|
||||
|
||||
```
|
||||
v3 (기존): 4개 모드 각각 컴포넌트 위치 설정 → 4배 작업량
|
||||
v4 (신규): 3가지 규칙만 설정 → 모든 화면 자동 적응
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 규칙 3가지
|
||||
|
||||
### 1. 크기 규칙 (Size Rules)
|
||||
|
||||
각 컴포넌트의 **너비**와 **높이**를 어떻게 결정할지 정합니다.
|
||||
|
||||
| 모드 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| **fixed** | 고정 px | 버튼 높이 48px |
|
||||
| **fill** | 부모 공간 채움 | 입력창 너비 = 화면 너비 |
|
||||
| **hug** | 내용에 맞춤 | 라벨 너비 = 텍스트 길이 |
|
||||
|
||||
```typescript
|
||||
// 예시: 버튼
|
||||
{
|
||||
width: "fill", // 화면 너비에 맞춤
|
||||
height: "fixed", // 고정
|
||||
fixedHeight: 48 // 48px
|
||||
}
|
||||
|
||||
// 예시: 라벨
|
||||
{
|
||||
width: "hug", // 텍스트 길이만큼
|
||||
height: "hug" // 텍스트 높이만큼
|
||||
}
|
||||
```
|
||||
|
||||
#### 크기 모드 시각화
|
||||
|
||||
```
|
||||
fixed (고정):
|
||||
├────48px────┤
|
||||
┌────────────┐
|
||||
│ 버튼 │ ← 화면 커져도 48px 유지
|
||||
└────────────┘
|
||||
|
||||
fill (채움):
|
||||
├─────────────────────────────────┤
|
||||
┌─────────────────────────────────┐
|
||||
│ 입력창 │ ← 화면 크기에 맞춤
|
||||
└─────────────────────────────────┘
|
||||
|
||||
hug (맞춤):
|
||||
├──────┤
|
||||
┌──────┐
|
||||
│라벨 │ ← 내용 길이만큼만
|
||||
└──────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 배치 규칙 (Layout Rules)
|
||||
|
||||
컴포넌트들을 **어떻게 나열할지** 정합니다.
|
||||
|
||||
#### 스택 방향
|
||||
|
||||
```
|
||||
가로 스택 (horizontal): 세로 스택 (vertical):
|
||||
┌─────┬─────┬─────┐ ┌─────────────┐
|
||||
│ A │ B │ C │ │ A │
|
||||
└─────┴─────┴─────┘ ├─────────────┤
|
||||
│ B │
|
||||
├─────────────┤
|
||||
│ C │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
#### 설정 항목
|
||||
|
||||
| 항목 | 설명 | 옵션 |
|
||||
|------|------|------|
|
||||
| **direction** | 스택 방향 | horizontal / vertical |
|
||||
| **wrap** | 줄바꿈 허용 | true / false |
|
||||
| **gap** | 요소 간 간격 | 8 / 16 / 24 px |
|
||||
| **alignItems** | 교차축 정렬 | start / center / end / stretch |
|
||||
| **justifyContent** | 주축 정렬 | start / center / end / space-between |
|
||||
|
||||
```typescript
|
||||
// 예시: 버튼 그룹 (가로 배치)
|
||||
{
|
||||
direction: "horizontal",
|
||||
wrap: true, // 공간 부족하면 줄바꿈
|
||||
gap: 16, // 버튼 간격 16px
|
||||
alignItems: "center" // 세로 중앙 정렬
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 반응형 규칙 (Responsive Rules)
|
||||
|
||||
**화면이 좁아지면** 어떻게 바꿀지 정합니다.
|
||||
|
||||
```typescript
|
||||
// 예시: 768px 이하면 가로→세로 전환
|
||||
{
|
||||
direction: "horizontal",
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 768, // 768px 이하일 때
|
||||
direction: "vertical" // 세로로 바꿈
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 시각화
|
||||
|
||||
```
|
||||
768px 이상 (태블릿): 768px 이하 (모바일):
|
||||
┌─────┬─────┬─────┐ ┌─────────────┐
|
||||
│ A │ B │ C │ → │ A │
|
||||
└─────┴─────┴─────┘ │ B │
|
||||
│ C │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 실제 예시: 작업지시 화면
|
||||
|
||||
### 규칙 설정
|
||||
|
||||
```typescript
|
||||
{
|
||||
root: {
|
||||
type: "stack",
|
||||
direction: "vertical",
|
||||
gap: 16,
|
||||
padding: { top: 16, right: 16, bottom: 16, left: 16 },
|
||||
children: ["header", "form", "buttons"]
|
||||
},
|
||||
|
||||
containers: {
|
||||
"header": {
|
||||
type: "stack",
|
||||
direction: "horizontal",
|
||||
alignItems: "center",
|
||||
children: ["title", "status"]
|
||||
},
|
||||
"form": {
|
||||
type: "stack",
|
||||
direction: "vertical",
|
||||
gap: 12,
|
||||
children: ["field1", "field2", "field3"]
|
||||
},
|
||||
"buttons": {
|
||||
type: "stack",
|
||||
direction: "horizontal",
|
||||
gap: 12,
|
||||
responsive: [
|
||||
{ breakpoint: 480, direction: "vertical" }
|
||||
],
|
||||
children: ["cancelBtn", "submitBtn"]
|
||||
}
|
||||
},
|
||||
|
||||
components: {
|
||||
"title": { width: "hug", height: "hug" },
|
||||
"status": { width: "hug", height: "hug" },
|
||||
"field1": { width: "fill", height: "fixed", fixedHeight: 48 },
|
||||
"field2": { width: "fill", height: "fixed", fixedHeight: 48 },
|
||||
"field3": { width: "fill", height: "fixed", fixedHeight: 48 },
|
||||
"cancelBtn": { width: "fill", height: "fixed", fixedHeight: 48 },
|
||||
"submitBtn": { width: "fill", height: "fixed", fixedHeight: 48 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 결과
|
||||
|
||||
```
|
||||
태블릿 가로 (1024px) 모바일 세로 (375px)
|
||||
┌──────────────────────────┐ ┌─────────────────┐
|
||||
│ 작업지시 #1234 [진행중]│ │작업지시 [진행]│
|
||||
├──────────────────────────┤ ├─────────────────┤
|
||||
│ [품번____________] │ │[품번_________] │
|
||||
│ [품명____________] │ │[품명_________] │
|
||||
│ [수량____________] │ │[수량_________] │
|
||||
├──────────────────────────┤ ├─────────────────┤
|
||||
│ [취소] [작업완료] │ │[취소] │
|
||||
└──────────────────────────┘ │[작업완료] │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v3 vs v4 비교
|
||||
|
||||
| 항목 | v3 (기존) | v4 (신규) |
|
||||
|------|----------|----------|
|
||||
| **설계 방식** | 4개 모드 각각 위치 설정 | 3가지 규칙 설정 |
|
||||
| **작업량** | 4배 | 1배 |
|
||||
| **데이터** | col, row, colSpan, rowSpan | width, height, direction, gap |
|
||||
| **반응형** | 수동 (모드별 설정) | 자동 (브레이크포인트) |
|
||||
| **유지보수** | 4곳 수정 | 1곳 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 규칙 설계 체크리스트
|
||||
|
||||
### 크기 규칙
|
||||
- [ ] 터치 요소(버튼, 입력창) 높이: fixed 48px
|
||||
- [ ] 너비가 화면에 맞아야 하는 요소: fill
|
||||
- [ ] 내용 길이에 맞아야 하는 요소: hug
|
||||
|
||||
### 배치 규칙
|
||||
- [ ] 컴포넌트 나열 방향 결정 (가로/세로)
|
||||
- [ ] 간격 설정 (8/16/24px)
|
||||
- [ ] 정렬 방식 결정 (start/center/stretch)
|
||||
|
||||
### 반응형 규칙
|
||||
- [ ] 768px 이하에서 가로→세로 전환 필요한 곳
|
||||
- [ ] 480px 이하에서 추가 조정 필요한 곳
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [반응형 디자인 가이드](./RESPONSIVE_DESIGN_GUIDE.md) - 크기 기준
|
||||
- [크기 프리셋](./SIZE_PRESETS.md) - 컴포넌트별 기본값
|
||||
- [v4 구현 계획](./POP_V4_CONSTRAINT_SYSTEM_PLAN.md) - 전체 계획
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-02-04*
|
||||
|
|
@ -1,391 +0,0 @@
|
|||
# POP v4 통합 설계 모드 스펙
|
||||
|
||||
**작성일: 2026-02-04**
|
||||
**최종 업데이트: 2026-02-04**
|
||||
**상태: Phase 3 완료 (visibility + 줄바꿈 컴포넌트)**
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
v3/v4 탭을 제거하고, **v4 자동 모드를 기본**으로 하되 **모드별 오버라이드** 기능을 지원하는 통합 설계 방식.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 개념
|
||||
|
||||
### 기존 방식 (v3)
|
||||
```
|
||||
4개 모드 각각 설계 필요
|
||||
태블릿 가로: 버튼 → col 1, row 1
|
||||
태블릿 세로: 버튼 → col 1, row 5 (따로 설정)
|
||||
모바일 가로: 버튼 → col 1, row 1 (따로 설정)
|
||||
모바일 세로: 버튼 → col 1, row 10 (따로 설정)
|
||||
```
|
||||
|
||||
### 새로운 방식 (v4 통합)
|
||||
```
|
||||
기본: 태블릿 가로에서 규칙 설정
|
||||
버튼 → width: fill, height: 48px
|
||||
|
||||
결과: 모든 모드에 자동 적용
|
||||
태블릿 가로: 버튼 너비 1024px, 높이 48px
|
||||
태블릿 세로: 버튼 너비 768px, 높이 48px
|
||||
모바일 가로: 버튼 너비 667px, 높이 48px
|
||||
모바일 세로: 버튼 너비 375px, 높이 48px
|
||||
|
||||
예외: 특정 모드에서 편집하면 오버라이드
|
||||
모바일 세로: 버튼 높이 36px (수동 설정)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 현재 UI (Phase 1.5 완료)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ← 목록 화면명 *변경됨 [↶][↷] 자동 레이아웃 (v4) [저장] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 편집 중: v4 (자동 반응형) │
|
||||
│ 규칙 기반 레이아웃 │
|
||||
├────────────┬────────────────────────────────────┬───────────────┤
|
||||
│ 컴포넌트 │ 미리보기: [모바일↕][모바일↔] │ 속성 │
|
||||
│ │ [태블릿↕][태블릿↔(기본)] │ │
|
||||
│ 필드 │ 너비: [====●====] 1024 x 768 │ │
|
||||
│ 버튼 │ │ │
|
||||
│ 리스트 │ ┌──────────────────────────────┐ │ 탭: 크기 │
|
||||
│ 인디케이터 │ │ [필드1] [필드2] [필드3] │ │ 설정 │
|
||||
│ 스캐너 │ │ [필드4] [Spacer] [버튼] │ │ 표시 ⬅ 🆕│
|
||||
│ 숫자패드 │ │ │ │ 데이터 │
|
||||
│ 스페이서 │ │ (가로 배치 + 자동 줄바꿈) │ │ │
|
||||
│ 줄바꿈 🆕 │ │ (스크롤 가능) │ │ │
|
||||
│ │ └──────────────────────────────┘ │ │
|
||||
│ │ 태블릿 가로 (1024x768) │ │
|
||||
└────────────┴────────────────────────────────────┴───────────────┘
|
||||
```
|
||||
|
||||
### 레이아웃 방식 (업계 표준)
|
||||
|
||||
| 서비스 | 방식 |
|
||||
|--------|------|
|
||||
| Figma | Auto Layout (Flexbox) |
|
||||
| Webflow | Flexbox + CSS Grid |
|
||||
| FlutterFlow | Row/Column/Stack |
|
||||
| Adalo 2.0 | Flexbox + Constraints |
|
||||
| **POP v4** | **Flexbox (horizontal + wrap)** |
|
||||
|
||||
### 특수 컴포넌트 사용법
|
||||
|
||||
#### Spacer (빈 공간)
|
||||
```
|
||||
[버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로
|
||||
[Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로
|
||||
[Spacer(fill)] [컴포넌트] → 컴포넌트가 오른쪽으로
|
||||
```
|
||||
|
||||
#### 줄바꿈 (Break) 🆕 Phase 3
|
||||
```
|
||||
[필드A] [필드B] [줄바꿈] [필드C] → 필드C가 새 줄로 이동
|
||||
|
||||
태블릿: [필드A] [필드B] [필드C] ← 줄바꿈 숨김 (한 줄)
|
||||
모바일: [필드A] [필드B] ← 줄바꿈 표시 (두 줄)
|
||||
[필드C]
|
||||
```
|
||||
|
||||
### 프리셋 버튼 (4개 모드)
|
||||
|
||||
| 버튼 | 해상도 | 설명 |
|
||||
|------|--------|------|
|
||||
| 모바일↕ | 375 x 667 | 모바일 세로 |
|
||||
| 모바일↔ | 667 x 375 | 모바일 가로 |
|
||||
| 태블릿↕ | 768 x 1024 | 태블릿 세로 |
|
||||
| 태블릿↔* | 1024 x 768 | 태블릿 가로 (기본) |
|
||||
|
||||
### 레이아웃 판별 로직
|
||||
|
||||
```typescript
|
||||
// 새 화면 또는 빈 레이아웃 → v4로 시작
|
||||
const hasValidLayout = loadedLayout && loadedLayout.version;
|
||||
const hasComponents = loadedLayout?.components &&
|
||||
Object.keys(loadedLayout.components).length > 0;
|
||||
|
||||
if (hasValidLayout && hasComponents) {
|
||||
// v4면 v4, 그 외 v3로 변환
|
||||
} else {
|
||||
// v4로 새로 시작
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 오버라이드 동작 (Phase 2 예정)
|
||||
|
||||
### 자동 감지 방식
|
||||
1. 사용자가 **태블릿 가로(기본)**에서 편집 → 기본 규칙 저장
|
||||
2. 사용자가 **다른 모드**에서 편집 → 해당 모드 오버라이드 자동 저장
|
||||
3. 편집 안 한 모드 → 기본 규칙에서 자동 계산
|
||||
|
||||
### 편집 상태 표시
|
||||
|
||||
| 상태 | 버튼 색상 | 설명 |
|
||||
|------|----------|------|
|
||||
| 기본 (태블릿 가로) | 강조 + "(기본)" | 항상 표시 |
|
||||
| 자동 | 기본 색상 | 편집 안 함 |
|
||||
| 편집됨 | 강조 색상 | 오버라이드 있음 |
|
||||
|
||||
### 되돌리기
|
||||
- 편집된 모드에만 "자동으로 되돌리기" 버튼 활성화
|
||||
- 클릭 시 오버라이드 삭제 → 기본 규칙 복원
|
||||
|
||||
---
|
||||
|
||||
## 데이터 구조
|
||||
|
||||
### PopLayoutDataV4 (Phase 2에서 수정 예정)
|
||||
```typescript
|
||||
interface PopLayoutDataV4 {
|
||||
version: "pop-4.0";
|
||||
root: PopContainerV4;
|
||||
components: Record<string, PopComponentDefinitionV4>;
|
||||
dataFlow: PopDataFlow;
|
||||
settings: PopGlobalSettingsV4;
|
||||
|
||||
// 모드별 오버라이드 (Phase 2에서 추가)
|
||||
overrides?: {
|
||||
mobile_portrait?: ModeOverride;
|
||||
mobile_landscape?: ModeOverride;
|
||||
tablet_portrait?: ModeOverride;
|
||||
// tablet_landscape는 기본이므로 오버라이드 없음
|
||||
};
|
||||
}
|
||||
|
||||
interface ModeOverride {
|
||||
components?: Record<string, Partial<PopComponentDefinitionV4>>;
|
||||
containers?: Record<string, Partial<PopContainerV4>>;
|
||||
}
|
||||
```
|
||||
|
||||
### PopComponentDefinitionV4 (Phase 3에서 수정 예정)
|
||||
```typescript
|
||||
interface PopComponentDefinitionV4 {
|
||||
type: PopComponentType;
|
||||
label?: string;
|
||||
size: PopSizeConstraintV4;
|
||||
alignSelf?: "start" | "center" | "end" | "stretch";
|
||||
|
||||
// 모드별 표시 설정 (Phase 3에서 추가)
|
||||
visibility?: {
|
||||
mobile_portrait?: boolean; // 기본 true
|
||||
mobile_landscape?: boolean; // 기본 true
|
||||
tablet_portrait?: boolean; // 기본 true
|
||||
tablet_landscape?: boolean; // 기본 true
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 표시/숨김 (Phase 3 예정)
|
||||
|
||||
### 업계 표준 (Webflow, Figma)
|
||||
- 삭제가 아닌 **숨김** 처리
|
||||
- 특정 모드에서만 `display: none`
|
||||
- 언제든 다시 표시 가능
|
||||
|
||||
### UI (속성 패널)
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ 버튼 │
|
||||
├─────────────────────────┤
|
||||
│ 표시 설정 │
|
||||
│ [x] 모바일 세로 │
|
||||
│ [x] 모바일 가로 │
|
||||
│ [x] 태블릿 세로 │
|
||||
│ [x] 태블릿 가로 │
|
||||
│ │
|
||||
│ (체크 해제 = 숨김) │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 상태
|
||||
|
||||
### Phase 1: 기본 구조 (완료)
|
||||
- [x] v3/v4 탭 제거 (자동 판별)
|
||||
- [x] 새 화면 → v4로 시작
|
||||
- [x] 기존 v3 화면 → v3로 로드 (하위 호환)
|
||||
- [x] 4개 프리셋 버튼 (모바일↕, 모바일↔, 태블릿↕, 태블릿↔)
|
||||
- [x] 기본 프리셋 표시 (태블릿 가로 + "(기본)")
|
||||
- [x] 슬라이더 유지 (320~1200px, 비율 유지)
|
||||
- [x] ComponentPaletteV4 생성
|
||||
|
||||
### Phase 1.5: Flexbox 가로 배치 (완료)
|
||||
- [x] Undo/Redo (Ctrl+Z / Ctrl+Shift+Z, 데스크탑 모드와 동일 방식)
|
||||
- [x] 드래그 리사이즈 핸들
|
||||
- [x] Flexbox 가로 배치 (`direction: horizontal`, `wrap: true`)
|
||||
- [x] 컴포넌트 타입별 기본 크기 설정
|
||||
- [x] Spacer 컴포넌트 (`pop-spacer`)
|
||||
- [x] 컴포넌트 순서 변경 (드래그 앤 드롭)
|
||||
- [x] 디바이스 스크린 무한 스크롤
|
||||
|
||||
### Phase 1.6: 비율 스케일링 시스템 (완료)
|
||||
- [x] 기준 너비 1024px (10인치 태블릿 가로)
|
||||
- [x] 최대 너비 1366px (12인치 태블릿)
|
||||
- [x] 뷰포트 감지 및 resize 이벤트 리스너
|
||||
- [x] 컴포넌트 크기 스케일 적용 (fixedWidth/Height)
|
||||
- [x] 컨테이너 스케일 적용 (gap, padding)
|
||||
- [x] 디자인 모드 분리 (scale=1)
|
||||
- [x] DndProvider 에러 수정
|
||||
|
||||
### Phase 2: 오버라이드 기능 (다음)
|
||||
- [ ] ModeOverride 데이터 구조 추가
|
||||
- [ ] 편집 감지 → 자동 오버라이드 저장
|
||||
- [ ] 편집 상태 표시 (버튼 색상)
|
||||
- [ ] "자동으로 되돌리기" 버튼
|
||||
|
||||
### Phase 3: 컴포넌트 표시/숨김
|
||||
- [ ] visibility 속성 추가
|
||||
- [ ] 속성 패널 체크박스 UI
|
||||
- [ ] 렌더러에서 visibility 처리
|
||||
|
||||
### Phase 4: 순서 오버라이드
|
||||
- [ ] 모드별 children 순서 오버라이드
|
||||
- [ ] 드래그로 순서 변경 UI
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
| 파일 | 역할 | 상태 |
|
||||
|------|------|------|
|
||||
| `PopDesigner.tsx` | v3/v4 통합 디자이너 | 완료 |
|
||||
| `PopCanvasV4.tsx` | v4 캔버스 (4개 프리셋 + 슬라이더) | 완료 |
|
||||
| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 + 비율 스케일링 | 완료 |
|
||||
| `ComponentPaletteV4.tsx` | v4 컴포넌트 팔레트 | 완료 |
|
||||
| `ComponentEditorPanelV4.tsx` | v4 속성 편집 패널 | 완료 |
|
||||
| `pop-layout.ts` | v3/v4 타입 정의 | 완료, Phase 2-3에서 수정 예정 |
|
||||
| `page.tsx` (뷰어) | v4 뷰어 + viewportWidth 감지 | 완료 |
|
||||
|
||||
---
|
||||
|
||||
## 비율 스케일링 시스템
|
||||
|
||||
### 업계 표준
|
||||
Rockwell Automation HMI의 "Scale with Fixed Aspect Ratio" 방식 적용
|
||||
|
||||
### 원리
|
||||
10인치(1024px) 기준으로 디자인 → 8~12인치에서 배치 유지, 크기만 비례 조정
|
||||
|
||||
### 계산
|
||||
```
|
||||
scale = viewportWidth / 1024
|
||||
scaledWidth = originalWidth * scale
|
||||
scaledHeight = originalHeight * scale
|
||||
scaledGap = originalGap * scale
|
||||
scaledPadding = originalPadding * scale
|
||||
```
|
||||
|
||||
### 화면별 결과
|
||||
|
||||
| 화면 | scale | 200px 컴포넌트 | 8px gap |
|
||||
|------|-------|----------------|---------|
|
||||
| 8인치 (800px) | 0.78 | 156px | 6px |
|
||||
| 10인치 (1024px) | 1.00 | 200px | 8px |
|
||||
| 12인치 (1366px) | 1.33 | 266px | 11px |
|
||||
| 14인치+ | 1.33 (max) | 266px + 여백 | 11px |
|
||||
|
||||
### 적용 위치
|
||||
|
||||
| 파일 | 함수/변수 | 역할 |
|
||||
|------|----------|------|
|
||||
| `PopFlexRenderer.tsx` | `BASE_VIEWPORT_WIDTH` | 기준 너비 상수 (1024) |
|
||||
| `PopFlexRenderer.tsx` | `calculateSizeStyle(size, settings, scale)` | 크기 스케일 적용 |
|
||||
| `PopFlexRenderer.tsx` | `ContainerRenderer.containerStyle` | gap, padding 스케일 적용 |
|
||||
| `page.tsx` | `viewportWidth` state | 뷰포트 너비 감지 |
|
||||
| `page.tsx` | `Math.min(window.innerWidth, 1366)` | 최대 너비 제한 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Visibility + 줄바꿈 컴포넌트 (완료) ✅
|
||||
|
||||
### 개요
|
||||
모드별 컴포넌트 표시/숨김 제어 및 강제 줄바꿈 기능 추가.
|
||||
|
||||
### 추가 타입
|
||||
|
||||
#### visibility 속성
|
||||
```typescript
|
||||
interface PopComponentDefinitionV4 {
|
||||
// 기존 속성...
|
||||
|
||||
// 🆕 모드별 표시/숨김
|
||||
visibility?: {
|
||||
tablet_landscape?: boolean;
|
||||
tablet_portrait?: boolean;
|
||||
mobile_landscape?: boolean;
|
||||
mobile_portrait?: boolean;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### pop-break 컴포넌트
|
||||
```typescript
|
||||
type PopComponentType =
|
||||
| "pop-field"
|
||||
| "pop-button"
|
||||
| "pop-list"
|
||||
| "pop-indicator"
|
||||
| "pop-scanner"
|
||||
| "pop-numpad"
|
||||
| "pop-spacer"
|
||||
| "pop-break"; // 🆕 줄바꿈
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
#### 모바일 전용 버튼
|
||||
```typescript
|
||||
{
|
||||
id: "call-button",
|
||||
type: "pop-button",
|
||||
label: "전화 걸기",
|
||||
visibility: {
|
||||
tablet_landscape: false, // 태블릿: 숨김
|
||||
mobile_portrait: true, // 모바일: 표시
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 모드별 줄바꿈
|
||||
```
|
||||
레이아웃: [A] [B] [줄바꿈] [C] [D]
|
||||
|
||||
줄바꿈 visibility: { tablet_landscape: false, mobile_portrait: true }
|
||||
|
||||
결과:
|
||||
태블릿: [A] [B] [C] [D] (한 줄)
|
||||
모바일: [A] [B] (두 줄)
|
||||
[C] [D]
|
||||
```
|
||||
|
||||
### 속성 패널 "표시" 탭
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ 탭: 크기 설정 표시 📍│
|
||||
├─────────────────────┤
|
||||
│ 모드별 표시 설정 │
|
||||
│ ☑ 태블릿 가로 │
|
||||
│ ☑ 태블릿 세로 │
|
||||
│ ☐ 모바일 가로 (숨김)│
|
||||
│ ☑ 모바일 세로 │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### 참고 문서
|
||||
- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계
|
||||
|
||||
---
|
||||
|
||||
*이 문서는 v4 통합 설계 모드의 스펙을 정의합니다.*
|
||||
*최종 업데이트: 2026-02-04 (Phase 3 완료)*
|
||||
|
|
@ -1,461 +0,0 @@
|
|||
# POP 컴포넌트 상세 설계서
|
||||
|
||||
> AI 에이전트 안내: Quick Reference 먼저 확인 후 필요한 섹션만 참조
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 총 컴포넌트 수: 15개 (🆕 줄바꿈 추가)
|
||||
|
||||
| 분류 | 개수 | 컴포넌트 |
|
||||
|------|------|----------|
|
||||
| 레이아웃 | 4 | container, tab-panel, **spacer**, **break 🆕** |
|
||||
| 데이터 표시 | 4 | data-table, card-list, kpi-gauge, status-indicator |
|
||||
| 입력 | 4 | number-pad, barcode-scanner, form-field, action-button |
|
||||
| 특화 기능 | 3 | timer, alarm-list, process-flow |
|
||||
|
||||
### 개발 우선순위
|
||||
|
||||
1단계: number-pad, status-indicator, kpi-gauge, action-button
|
||||
2단계: data-table, card-list, barcode-scanner, timer
|
||||
3단계: container, tab-panel, form-field, alarm-list, process-flow
|
||||
|
||||
### POP UI 필수 원칙
|
||||
|
||||
- 버튼 최소 크기: 48px (1.5cm)
|
||||
- 고대비 테마 지원
|
||||
- 단순 탭 조작 (스와이프 최소화)
|
||||
- 알람에만 원색 사용
|
||||
- 숫자 우측 정렬
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 목록
|
||||
|
||||
| # | 컴포넌트 | 역할 |
|
||||
|---|----------|------|
|
||||
| 1 | pop-container | 레이아웃 뼈대 |
|
||||
| 2 | pop-tab-panel | 정보 분류 |
|
||||
| 3 | **pop-spacer** | **빈 공간 (정렬용)** |
|
||||
| 4 | **pop-break 🆕** | **강제 줄바꿈 (Flexbox)** |
|
||||
| 5 | pop-data-table | 대량 데이터 |
|
||||
| 6 | pop-card-list | 시각적 목록 |
|
||||
| 7 | pop-kpi-gauge | 목표 달성률 |
|
||||
| 8 | pop-status-indicator | 상태 표시 |
|
||||
| 9 | pop-number-pad | 수량 입력 |
|
||||
| 10 | pop-barcode-scanner | 스캔 입력 |
|
||||
| 11 | pop-form-field | 범용 입력 |
|
||||
| 12 | pop-action-button | 작업 실행 |
|
||||
| 13 | pop-timer | 시간 측정 |
|
||||
| 14 | pop-alarm-list | 알람 관리 |
|
||||
| 15 | pop-process-flow | 공정 현황 |
|
||||
|
||||
---
|
||||
|
||||
## 1. pop-container
|
||||
|
||||
역할: 모든 컴포넌트의 부모, 화면 뼈대
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 반응형 그리드 | 모바일/태블릿 자동 대응 |
|
||||
| 플렉스 방향 | row, column, wrap |
|
||||
| 간격 설정 | gap, padding |
|
||||
| 배경/테두리 | 색상, 둥근모서리 |
|
||||
| 스크롤 설정 | 가로/세로/없음 |
|
||||
|
||||
---
|
||||
|
||||
## 2. pop-tab-panel
|
||||
|
||||
역할: 정보 분류, 화면 공간 효율화
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 탭 모드 | 상단/하단/좌측 탭 |
|
||||
| 아코디언 모드 | 접기/펼치기 |
|
||||
| 아이콘 지원 | 탭별 아이콘 |
|
||||
| 뱃지 표시 | 알림 개수 표시 |
|
||||
| 기본 활성 탭 | 초기 선택 설정 |
|
||||
|
||||
---
|
||||
|
||||
## 3. pop-spacer (v4 전용)
|
||||
|
||||
역할: 빈 공간을 차지하여 레이아웃 정렬에 사용 (Figma, Webflow 등 업계 표준)
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 공간 채우기 | 남은 공간을 자동으로 채움 (`width: fill`) |
|
||||
| 고정 크기 | 특정 크기의 빈 공간 (`width: fixed`) |
|
||||
| 정렬 용도 | 컴포넌트를 오른쪽/가운데 정렬 |
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```
|
||||
[버튼A] [Spacer(fill)] [버튼B] → 버튼B가 오른쪽 끝으로
|
||||
[Spacer] [컴포넌트] [Spacer] → 컴포넌트가 가운데로
|
||||
[Spacer(fill)] [컴포넌트] → 컴포넌트가 오른쪽으로
|
||||
```
|
||||
|
||||
### 기본 설정
|
||||
|
||||
| 속성 | 기본값 |
|
||||
|------|--------|
|
||||
| width | fill (남은 공간 채움) |
|
||||
| height | 48px (고정) |
|
||||
| 디자인 모드 표시 | 점선 배경 + "빈 공간" 텍스트 |
|
||||
| 실제 모드 | 완전히 투명 (공간만 차지) |
|
||||
|
||||
---
|
||||
|
||||
## 4. pop-break (v4 전용) 🆕
|
||||
|
||||
역할: Flexbox에서 강제 줄바꿈을 위한 컴포넌트 (업계 표준: Figma Auto Layout의 줄바꿈과 동일)
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 강제 줄바꿈 | `flex-basis: 100%`로 다음 컴포넌트를 새 줄로 이동 |
|
||||
| 모드별 표시 | visibility 속성으로 특정 모드에서만 줄바꿈 적용 |
|
||||
| 시각적 표시 | 디자인 모드에서만 점선으로 표시 |
|
||||
| 실제 화면 | 높이 0px (완전히 보이지 않음) |
|
||||
|
||||
### 동작 원리
|
||||
|
||||
```
|
||||
Flexbox wrap: true 상태에서
|
||||
flex-basis: 100%를 가진 요소 → 전체 너비 차지 → 다음 요소는 자동으로 새 줄로 이동
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```
|
||||
[필드A] [필드B] [줄바꿈] [필드C] [필드D]
|
||||
|
||||
결과:
|
||||
┌────────────────────┐
|
||||
│ [필드A] [필드B] │ ← 첫째 줄
|
||||
│ [필드C] [필드D] │ ← 둘째 줄 (줄바꿈 후)
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
### 모드별 줄바꿈
|
||||
|
||||
```typescript
|
||||
// 줄바꿈 컴포넌트 설정
|
||||
{
|
||||
id: "break-1",
|
||||
type: "pop-break",
|
||||
visibility: {
|
||||
tablet_landscape: false, // 태블릿: 줄바꿈 숨김 (한 줄)
|
||||
mobile_portrait: true, // 모바일: 줄바꿈 표시 (두 줄)
|
||||
}
|
||||
}
|
||||
|
||||
// 결과
|
||||
태블릿: [A] [B] [C] [D] (한 줄)
|
||||
모바일: [A] [B] (두 줄)
|
||||
[C] [D]
|
||||
```
|
||||
|
||||
### 기본 설정
|
||||
|
||||
| 속성 | 기본값 |
|
||||
|------|--------|
|
||||
| width | fill (`flex-basis: 100%`) |
|
||||
| height | 0px (높이 없음) |
|
||||
| 디자인 모드 표시 | 점선 + "줄바꿈" 텍스트 (높이 16px) |
|
||||
| 실제 모드 | 완전히 투명 (높이 0px) |
|
||||
| flex-basis | 100% (핵심 속성) |
|
||||
|
||||
### CSS 구현
|
||||
|
||||
```css
|
||||
/* 디자인 모드 */
|
||||
.pop-break-design {
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 실제 모드 */
|
||||
.pop-break-runtime {
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 업계 비교
|
||||
|
||||
| 서비스 | 줄바꿈 방식 |
|
||||
|--------|------------|
|
||||
| Figma Auto Layout | "Wrap" 설정 + 수동 줄 분리 |
|
||||
| Webflow Flexbox | "Wrap" + 100% width spacer |
|
||||
| Framer | "Break" 컴포넌트 |
|
||||
| **POP v4** | **pop-break (flex-basis: 100%)** |
|
||||
|
||||
### 주의사항
|
||||
|
||||
- 컨테이너의 `wrap: true` 설정 필수
|
||||
- wrap이 false면 줄바꿈 무시됨
|
||||
- visibility로 모드별 제어 가능
|
||||
- 디자인 모드에서만 시각적으로 보임
|
||||
|
||||
---
|
||||
|
||||
## 5. pop-data-table
|
||||
|
||||
역할: 대량 데이터 표시, 선택, 편집
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 가상 스크롤 | 대량 데이터 성능 |
|
||||
| 행 선택 | 단일/다중 선택 |
|
||||
| 인라인 편집 | 셀 직접 수정 |
|
||||
| 정렬/필터 | 컬럼별 정렬, 검색 |
|
||||
| 고정 컬럼 | 좌측 컬럼 고정 |
|
||||
| 행 색상 조건 | 상태별 배경색 |
|
||||
| 큰 글씨 모드 | POP 전용 가독성 |
|
||||
|
||||
---
|
||||
|
||||
## 4. pop-card-list
|
||||
|
||||
역할: 시각적 강조가 필요한 목록
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 카드 레이아웃 | 1열/2열/3열 |
|
||||
| 이미지 표시 | 부품/제품 사진 |
|
||||
| 상태 뱃지 | 진행중/완료/대기 |
|
||||
| 진행률 바 | 작업 진척도 |
|
||||
| 스와이프 액션 | 완료/삭제 (선택적) |
|
||||
| 클릭 이벤트 | 상세 모달 연결 |
|
||||
|
||||
---
|
||||
|
||||
## 5. pop-kpi-gauge
|
||||
|
||||
역할: 목표 대비 실적 시각화
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 게이지 타입 | 원형/반원형/수평바 |
|
||||
| 목표값 | 기준선 표시 |
|
||||
| 현재값 | 실시간 바인딩 |
|
||||
| 색상 구간 | 위험/경고/정상 |
|
||||
| 단위 표시 | %, 개, 초 등 |
|
||||
| 애니메이션 | 값 변경 시 전환 |
|
||||
| 라벨 | 제목, 부제목 |
|
||||
|
||||
---
|
||||
|
||||
## 6. pop-status-indicator
|
||||
|
||||
역할: 설비/공정 상태 즉시 파악
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 표시 타입 | 원형/사각/아이콘 |
|
||||
| 상태 매핑 | 값 -> 색상/아이콘 자동 |
|
||||
| 색상 설정 | 상태별 커스텀 |
|
||||
| 크기 | S/M/L/XL |
|
||||
| 깜빡임 | 알람 상태 강조 |
|
||||
| 라벨 위치 | 상단/하단/우측 |
|
||||
| 그룹 표시 | 여러 상태 한 줄 |
|
||||
|
||||
---
|
||||
|
||||
## 7. pop-number-pad
|
||||
|
||||
역할: 장갑 착용 상태 수량 입력
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 큰 버튼 | 최소 48px (1.5cm) |
|
||||
| 레이아웃 | 전화기식/계산기식 |
|
||||
| 소수점 | 허용/불허 |
|
||||
| 음수 | 허용/불허 |
|
||||
| 최소/최대값 | 범위 제한 |
|
||||
| 단위 표시 | 개, kg, m 등 |
|
||||
| 빠른 증감 | +1, +10, +100 버튼 |
|
||||
| 진동 피드백 | 터치 확인 |
|
||||
| 클리어 | 전체 삭제, 한 자리 삭제 |
|
||||
|
||||
---
|
||||
|
||||
## 8. pop-barcode-scanner
|
||||
|
||||
역할: 자재 투입, 로트 추적
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 카메라 스캔 | 모바일 카메라 연동 |
|
||||
| 외부 스캐너 | USB/블루투스 연동 |
|
||||
| 스캔 타입 | 바코드/QR/RFID |
|
||||
| 연속 스캔 | 다중 입력 모드 |
|
||||
| 이력 표시 | 최근 스캔 목록 |
|
||||
| 유효성 검증 | 포맷 체크 |
|
||||
| 자동 조회 | 스캔 후 API 연동 |
|
||||
| 소리/진동 | 스캔 성공 피드백 |
|
||||
|
||||
---
|
||||
|
||||
## 9. pop-form-field
|
||||
|
||||
역할: 텍스트, 선택, 날짜 등 범용 입력
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 입력 타입 | text/number/date/time/select |
|
||||
| 라벨 | 상단/좌측/플로팅 |
|
||||
| 필수 표시 | 별표, 색상 |
|
||||
| 유효성 검증 | 실시간 체크 |
|
||||
| 에러 메시지 | 하단 표시 |
|
||||
| 비활성화 | 읽기 전용 모드 |
|
||||
| 큰 사이즈 | POP 전용 높이 |
|
||||
| 도움말 | 툴팁/하단 설명 |
|
||||
|
||||
---
|
||||
|
||||
## 10. pop-action-button
|
||||
|
||||
역할: 작업 실행, 상태 변경
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 크기 | S/M/L/XL/전체너비 |
|
||||
| 스타일 | primary/secondary/danger/success |
|
||||
| 아이콘 | 좌측/우측/단독 |
|
||||
| 로딩 상태 | 스피너 표시 |
|
||||
| 비활성화 | 조건부 |
|
||||
| 확인 다이얼로그 | 위험 작업 전 확인 |
|
||||
| 길게 누르기 | 특수 동작 (선택적) |
|
||||
| 뱃지 | 개수 표시 |
|
||||
|
||||
---
|
||||
|
||||
## 11. pop-timer
|
||||
|
||||
역할: 사이클 타임, 비가동 시간 측정
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 모드 | 스톱워치/카운트다운 |
|
||||
| 시작/정지/리셋 | 기본 제어 |
|
||||
| 랩 타임 | 구간 기록 |
|
||||
| 목표 시간 | 초과 시 알림 |
|
||||
| 표시 형식 | HH:MM:SS / MM:SS |
|
||||
| 크기 | 작은/중간/큰 |
|
||||
| 배경 색상 | 상태별 변경 |
|
||||
| 자동 시작 | 조건부 트리거 |
|
||||
|
||||
---
|
||||
|
||||
## 12. pop-alarm-list
|
||||
|
||||
역할: 이상 상황 알림 및 확인
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 우선순위 | 긴급/경고/정보 |
|
||||
| 색상 구분 | 레벨별 배경색 |
|
||||
| 시간 표시 | 발생 시각 |
|
||||
| 확인(Ack) | 알람 인지 처리 |
|
||||
| 필터 | 미확인만/전체 |
|
||||
| 정렬 | 시간순/우선순위순 |
|
||||
| 상세 보기 | 클릭 시 모달 |
|
||||
| 소리 알림 | 신규 알람 |
|
||||
|
||||
---
|
||||
|
||||
## 13. pop-process-flow
|
||||
|
||||
역할: 전체 공정 현황 시각화
|
||||
|
||||
| 기능 | 설명 |
|
||||
|------|------|
|
||||
| 노드 타입 | 공정/검사/대기 |
|
||||
| 연결선 | 화살표, 분기 |
|
||||
| 현재 위치 | 강조 표시 |
|
||||
| 상태 색상 | 완료/진행/대기 |
|
||||
| 클릭 이벤트 | 공정 상세 이동 |
|
||||
| 가로/세로 | 방향 설정 |
|
||||
| 축소/확대 | 핀치 줌 |
|
||||
| 진행률 | 전체 대비 현재 |
|
||||
|
||||
---
|
||||
|
||||
## 커버 가능한 시나리오
|
||||
|
||||
이 13개 컴포넌트 조합으로 대응 가능한 화면:
|
||||
|
||||
- 작업 지시 화면
|
||||
- 실적 입력 화면
|
||||
- 품질 검사 화면
|
||||
- 설비 모니터링 대시보드
|
||||
- 자재 투입/출고 화면
|
||||
- 알람 관리 화면
|
||||
- 공정 현황판
|
||||
|
||||
---
|
||||
|
||||
## 기존 컴포넌트 재사용 가능 목록
|
||||
|
||||
| POP 컴포넌트 | 기존 컴포넌트 | 수정 필요 |
|
||||
|-------------|--------------|----------|
|
||||
| pop-data-table | v2-table-widget | 큰 글씨 모드 추가 |
|
||||
| pop-form-field | v2-input-widget, v2-select-widget | 큰 사이즈 옵션 |
|
||||
| pop-action-button | v2-button-widget | 크기/확인 다이얼로그 |
|
||||
| pop-tab-panel | tab-widget | POP 스타일 적용 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 업데이트 (2026-02-04) 🆕
|
||||
|
||||
### 추가된 컴포넌트
|
||||
|
||||
#### pop-break (줄바꿈)
|
||||
- **역할**: Flexbox에서 강제 줄바꿈
|
||||
- **핵심 기술**: `flex-basis: 100%`
|
||||
- **모드별 제어**: visibility 속성 지원
|
||||
- **시각적 표시**: 디자인 모드에서만 점선 표시 (실제 높이 0px)
|
||||
|
||||
### 모든 컴포넌트 공통 추가 속성
|
||||
|
||||
#### visibility (모드별 표시/숨김)
|
||||
```typescript
|
||||
visibility?: {
|
||||
tablet_landscape?: boolean;
|
||||
tablet_portrait?: boolean;
|
||||
mobile_landscape?: boolean;
|
||||
mobile_portrait?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**사용 예시**:
|
||||
```typescript
|
||||
// 모바일 전용 버튼
|
||||
{
|
||||
type: "pop-action-button",
|
||||
visibility: {
|
||||
tablet_landscape: false,
|
||||
mobile_portrait: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 참고 문서
|
||||
- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계
|
||||
- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - v4 통합 설계
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-02-04 (Phase 3 완료)*
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
# ADR-001: v4 제약조건 기반 레이아웃 채택
|
||||
|
||||
**날짜**: 2026-02-03
|
||||
**상태**: 채택됨
|
||||
**의사결정자**: 프로젝트 담당자
|
||||
|
||||
---
|
||||
|
||||
## 배경
|
||||
|
||||
v3에서는 4개 모드(tablet_landscape, tablet_portrait, mobile_landscape, mobile_portrait)에 대해 각각 컴포넌트 위치를 설정해야 했습니다.
|
||||
|
||||
**문제점**:
|
||||
1. 같은 컴포넌트를 4번 배치해야 함 (4배 작업량)
|
||||
2. 모드 간 일관성 유지 어려움
|
||||
3. 새 모드 추가 시 또 다른 배치 필요
|
||||
|
||||
---
|
||||
|
||||
## 결정
|
||||
|
||||
**"단일 소스 + 자동 적응" 방식 채택**
|
||||
|
||||
Figma, Framer, Flutter, SwiftUI에서 사용하는 업계 표준 접근법:
|
||||
- 하나의 레이아웃 정의
|
||||
- 제약조건(constraints) 설정
|
||||
- 모든 화면에 자동 적응
|
||||
|
||||
---
|
||||
|
||||
## 핵심 규칙 3가지
|
||||
|
||||
### 1. 크기 규칙 (Size Rules)
|
||||
|
||||
| 모드 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| fixed | 고정 px | 버튼 높이 48px |
|
||||
| fill | 부모 채움 | 입력창 100% |
|
||||
| hug | 내용 맞춤 | 라벨 = 텍스트 길이 |
|
||||
|
||||
### 2. 배치 규칙 (Layout Rules)
|
||||
|
||||
- 스택 방향: horizontal / vertical
|
||||
- 줄바꿈: wrap / nowrap
|
||||
- 간격: gap (8/16/24px)
|
||||
- 정렬: start / center / end / stretch
|
||||
|
||||
### 3. 반응형 규칙 (Responsive Rules)
|
||||
|
||||
```typescript
|
||||
{
|
||||
direction: "horizontal",
|
||||
responsive: [
|
||||
{ breakpoint: 768, direction: "vertical" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 방식
|
||||
|
||||
**Flexbox 기반** (CSS Grid 아님)
|
||||
|
||||
이유:
|
||||
- 1차원 배치에 최적화
|
||||
- 자동 크기 계산 (hug)
|
||||
- 반응형 전환 간단
|
||||
|
||||
---
|
||||
|
||||
## 대안 검토
|
||||
|
||||
### A. 기존 4모드 유지 (기각)
|
||||
|
||||
장점: 기존 코드 변경 없음
|
||||
단점: 근본 문제 해결 안 됨
|
||||
|
||||
### B. CSS Grid 기반 (기각)
|
||||
|
||||
장점: 2차원 배치 가능
|
||||
단점: hug 구현 복잡, 학습 곡선
|
||||
|
||||
### C. 제약조건 기반 (채택)
|
||||
|
||||
장점: 업계 표준, 1회 설계
|
||||
단점: 기존 v3와 호환성 고려 필요
|
||||
|
||||
---
|
||||
|
||||
## 영향
|
||||
|
||||
### 변경 필요
|
||||
|
||||
- 타입 정의 (PopLayoutDataV4)
|
||||
- 렌더러 (Flexbox 기반)
|
||||
- 디자이너 UI (제약조건 편집)
|
||||
|
||||
### 호환성
|
||||
|
||||
- v3 레이아웃은 기존 방식으로 계속 작동
|
||||
- v4는 새로운 레이아웃에만 적용
|
||||
- 점진적 마이그레이션 가능
|
||||
|
||||
---
|
||||
|
||||
## 참조
|
||||
|
||||
- Figma Auto Layout: https://help.figma.com/hc/en-us/articles/5731482952599-Using-auto-layout
|
||||
- Flutter Flex: https://docs.flutter.dev/development/ui/layout
|
||||
- SwiftUI Stacks: https://developer.apple.com/documentation/swiftui/hstack
|
||||
|
||||
---
|
||||
|
||||
## 관련
|
||||
|
||||
- rangraph 검색: "v4 constraint", "layout system"
|
||||
- SPEC.md: 상세 규칙
|
||||
- PLAN.md: 구현 로드맵
|
||||
|
|
@ -1,690 +0,0 @@
|
|||
# Phase 3: Visibility + 줄바꿈 컴포넌트 구현
|
||||
|
||||
**날짜**: 2026-02-04
|
||||
**상태**: 구현 완료 ✅
|
||||
**관련 이슈**: 모드별 컴포넌트 표시/숨김, 강제 줄바꿈
|
||||
|
||||
---
|
||||
|
||||
## 📋 목표
|
||||
|
||||
Phase 2의 배치 고정 기능에 이어, 다음 기능들을 추가:
|
||||
1. **모드별 컴포넌트 표시/숨김** (visibility)
|
||||
2. **강제 줄바꿈 컴포넌트** (pop-break)
|
||||
3. **컴포넌트 오버라이드 병합** (모드별 설정 변경)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 문제 정의
|
||||
|
||||
### 문제 1: 모드별 컴포넌트 추가/삭제 불가
|
||||
```
|
||||
현재 상황:
|
||||
- 모든 모드에서 같은 컴포넌트만 표시 가능
|
||||
- 모바일 전용 버튼(예: "전화 걸기")을 추가할 수 없음
|
||||
|
||||
요구사항:
|
||||
- 특정 모드에서만 컴포넌트 표시
|
||||
- 다른 모드에서는 자동 숨김
|
||||
```
|
||||
|
||||
### 문제 2: Flexbox에서 강제 줄바꿈 불가
|
||||
```
|
||||
현재 상황:
|
||||
- wrap: true여도 컴포넌트가 공간을 채워야 줄바꿈
|
||||
- [A] [B] [C] → 강제로 [A] [B] / [C] 불가능
|
||||
|
||||
요구사항:
|
||||
- 사용자가 원하는 위치에서 강제 줄바꿈
|
||||
- 디자인 모드에서 시각적으로 표시
|
||||
```
|
||||
|
||||
### 문제 3: 컴포넌트 설정을 모드별로 변경 불가
|
||||
```
|
||||
현재 상황:
|
||||
- 컨테이너 배치만 오버라이드 가능
|
||||
- 리스트 컬럼 수, 버튼 스타일 등은 모든 모드 동일
|
||||
|
||||
요구사항 (확장성):
|
||||
- 태블릿: 리스트 7개 컬럼
|
||||
- 모바일: 리스트 3개 컬럼
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 해결 방안
|
||||
|
||||
### 방안 A: children 배열 오버라이드 (추가/삭제)
|
||||
```typescript
|
||||
overrides: {
|
||||
mobile_portrait: {
|
||||
containers: {
|
||||
root: {
|
||||
children: ["comp1", "comp2", "mobile-only-button"] // 컴포넌트 추가
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**장점**:
|
||||
- 모드별로 완전히 다른 컴포넌트 구성 가능
|
||||
- 유연성 극대화
|
||||
|
||||
**단점**:
|
||||
- 데이터 동기화 복잡
|
||||
- 삭제/추가 시 다른 모드에도 영향
|
||||
- 순서 변경 시 충돌 가능
|
||||
|
||||
---
|
||||
|
||||
### 방안 B: visibility 속성 (표시/숨김) ✅ 채택
|
||||
```typescript
|
||||
interface PopComponentDefinitionV4 {
|
||||
visibility?: {
|
||||
tablet_landscape?: boolean;
|
||||
tablet_portrait?: boolean;
|
||||
mobile_landscape?: boolean;
|
||||
mobile_portrait?: boolean;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**장점**:
|
||||
- 단순하고 명확
|
||||
- 컴포넌트는 항상 존재 (숨김만)
|
||||
- 데이터 일관성 유지
|
||||
|
||||
**단점**:
|
||||
- 완전히 다른 컴포넌트 추가는 불가능
|
||||
- 많은 모드 전용 컴포넌트는 비효율적
|
||||
|
||||
---
|
||||
|
||||
### 최종 결정: 하이브리드 접근 ⭐
|
||||
```typescript
|
||||
1. visibility: 기본 기능 (Phase 3)
|
||||
- 간단한 표시/숨김
|
||||
- 줄바꿈 컴포넌트 제어
|
||||
|
||||
2. components 오버라이드: 고급 기능 (Phase 3 기반)
|
||||
- 컴포넌트 설정 변경 (리스트 컬럼 수 등)
|
||||
- 스타일 변경
|
||||
|
||||
3. children 오버라이드: 추후 고려
|
||||
- 모드별 완전히 다른 구성 필요 시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 구현 내용
|
||||
|
||||
### 1. 타입 정의 확장
|
||||
|
||||
#### pop-break 컴포넌트 추가
|
||||
```typescript
|
||||
export type PopComponentType =
|
||||
| "pop-field"
|
||||
| "pop-button"
|
||||
| "pop-list"
|
||||
| "pop-indicator"
|
||||
| "pop-scanner"
|
||||
| "pop-numpad"
|
||||
| "pop-spacer"
|
||||
| "pop-break"; // 🆕 줄바꿈
|
||||
```
|
||||
|
||||
#### visibility 속성 추가
|
||||
```typescript
|
||||
export interface PopComponentDefinitionV4 {
|
||||
id: string;
|
||||
type: PopComponentType;
|
||||
size: PopSizeConstraintV4;
|
||||
|
||||
// 🆕 모드별 표시/숨김
|
||||
visibility?: {
|
||||
tablet_landscape?: boolean; // undefined = true (기본 표시)
|
||||
tablet_portrait?: boolean;
|
||||
mobile_landscape?: boolean;
|
||||
mobile_portrait?: boolean;
|
||||
};
|
||||
|
||||
// 기존: 픽셀 기반 반응형
|
||||
hideBelow?: number;
|
||||
|
||||
// 기타...
|
||||
}
|
||||
```
|
||||
|
||||
#### 기본 크기 설정
|
||||
```typescript
|
||||
const defaultSizes: Record<PopComponentType, PopSizeConstraintV4> = {
|
||||
// ...
|
||||
"pop-break": {
|
||||
width: "fill", // 100% 너비 (flex-basis: 100%)
|
||||
height: "fixed",
|
||||
fixedHeight: 0, // 높이 0 (보이지 않음)
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 렌더러 로직 개선
|
||||
|
||||
#### visibility 체크 함수
|
||||
```typescript
|
||||
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
|
||||
if (!component.visibility) return true; // 기본값: 표시
|
||||
|
||||
const modeVisibility = component.visibility[currentMode];
|
||||
return modeVisibility !== false; // undefined도 true로 취급
|
||||
};
|
||||
```
|
||||
|
||||
**로직 설명**:
|
||||
- `visibility` 속성이 없으면 → 모든 모드에서 표시
|
||||
- `visibility.mobile_portrait === false` → 모바일 세로에서 숨김
|
||||
- `visibility.mobile_portrait === undefined` → 모바일 세로에서 표시 (기본값)
|
||||
|
||||
---
|
||||
|
||||
#### 컴포넌트 오버라이드 병합
|
||||
```typescript
|
||||
const getMergedComponent = (
|
||||
baseComponent: PopComponentDefinitionV4
|
||||
): PopComponentDefinitionV4 => {
|
||||
if (currentMode === "tablet_landscape") return baseComponent;
|
||||
|
||||
const componentOverride = overrides?.[currentMode]?.components?.[baseComponent.id];
|
||||
if (!componentOverride) return baseComponent;
|
||||
|
||||
// 깊은 병합 (config, size)
|
||||
return {
|
||||
...baseComponent,
|
||||
...componentOverride,
|
||||
size: { ...baseComponent.size, ...componentOverride.size },
|
||||
config: { ...baseComponent.config, ...componentOverride.config },
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**병합 우선순위**:
|
||||
1. `baseComponent` (기본값)
|
||||
2. `overrides[currentMode].components[id]` (모드별 오버라이드)
|
||||
3. 중첩 객체는 깊은 병합 (`size`, `config`)
|
||||
|
||||
**확장 가능성**:
|
||||
- 리스트 컬럼 수 변경
|
||||
- 버튼 스타일 변경
|
||||
- 필드 표시 형식 변경
|
||||
|
||||
---
|
||||
|
||||
#### pop-break 전용 렌더링
|
||||
```typescript
|
||||
// pop-break 특수 처리
|
||||
if (mergedComponent.type === "pop-break") {
|
||||
return (
|
||||
<div
|
||||
key={componentId}
|
||||
className={cn(
|
||||
"w-full",
|
||||
isDesignMode
|
||||
? "h-4 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400"
|
||||
: "h-0"
|
||||
)}
|
||||
style={{ flexBasis: "100%" }} // 핵심: 100% 너비로 줄바꿈 강제
|
||||
onClick={() => onComponentClick?.(componentId)}
|
||||
>
|
||||
{isDesignMode && (
|
||||
<span className="text-xs text-gray-400">줄바꿈</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**동작 방식**:
|
||||
- `flex-basis: 100%` → 컨테이너 전체 너비 차지
|
||||
- 다음 컴포넌트는 자동으로 새 줄로 이동
|
||||
- 디자인 모드: 점선 표시 (높이 16px)
|
||||
- 실제 화면: 높이 0 (안 보임)
|
||||
|
||||
---
|
||||
|
||||
### 3. 삭제 함수 개선
|
||||
|
||||
#### 오버라이드 정리 로직
|
||||
```typescript
|
||||
export const removeComponentFromV4Layout = (
|
||||
layout: PopLayoutDataV4,
|
||||
componentId: string
|
||||
): PopLayoutDataV4 => {
|
||||
// 1. 컴포넌트 정의 삭제
|
||||
const { [componentId]: _, ...remainingComponents } = layout.components;
|
||||
|
||||
// 2. root.children에서 제거
|
||||
const newRoot = removeChildFromContainer(layout.root, componentId);
|
||||
|
||||
// 3. 🆕 모든 오버라이드에서 제거
|
||||
const newOverrides = cleanupOverridesAfterDelete(layout.overrides, componentId);
|
||||
|
||||
return {
|
||||
...layout,
|
||||
root: newRoot,
|
||||
components: remainingComponents,
|
||||
overrides: newOverrides,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
#### 오버라이드 정리 상세
|
||||
```typescript
|
||||
function cleanupOverridesAfterDelete(
|
||||
overrides: PopLayoutDataV4["overrides"],
|
||||
componentId: string
|
||||
): PopLayoutDataV4["overrides"] {
|
||||
if (!overrides) return undefined;
|
||||
|
||||
const newOverrides = { ...overrides };
|
||||
|
||||
for (const mode of Object.keys(newOverrides)) {
|
||||
const override = newOverrides[mode];
|
||||
if (!override) continue;
|
||||
|
||||
const updated = { ...override };
|
||||
|
||||
// containers.root.children에서 제거
|
||||
if (updated.containers?.root?.children) {
|
||||
updated.containers = {
|
||||
...updated.containers,
|
||||
root: {
|
||||
...updated.containers.root,
|
||||
children: updated.containers.root.children.filter(id => id !== componentId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// components에서 제거
|
||||
if (updated.components?.[componentId]) {
|
||||
const { [componentId]: _, ...rest } = updated.components;
|
||||
updated.components = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
}
|
||||
|
||||
// 빈 오버라이드 정리
|
||||
if (!updated.containers && !updated.components) {
|
||||
delete newOverrides[mode];
|
||||
} else {
|
||||
newOverrides[mode] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 오버라이드가 비었으면 undefined 반환
|
||||
return Object.keys(newOverrides).length > 0 ? newOverrides : undefined;
|
||||
}
|
||||
```
|
||||
|
||||
**정리 항목**:
|
||||
1. `overrides[mode].containers.root.children` - 컴포넌트 ID 제거
|
||||
2. `overrides[mode].components[componentId]` - 컴포넌트 설정 제거
|
||||
3. 빈 오버라이드 객체 삭제 (메모리 절약)
|
||||
|
||||
---
|
||||
|
||||
### 4. 속성 패널 UI
|
||||
|
||||
#### "표시" 탭 추가
|
||||
```typescript
|
||||
<TabsList>
|
||||
<TabsTrigger value="size">크기</TabsTrigger>
|
||||
<TabsTrigger value="settings">설정</TabsTrigger>
|
||||
<TabsTrigger value="visibility">
|
||||
<Eye className="h-3 w-3" />
|
||||
표시
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="data">데이터</TabsTrigger>
|
||||
</TabsList>
|
||||
```
|
||||
|
||||
#### VisibilityForm 컴포넌트
|
||||
```typescript
|
||||
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
||||
const modes = [
|
||||
{ key: "tablet_landscape", label: "태블릿 가로 (1024×768)" },
|
||||
{ key: "tablet_portrait", label: "태블릿 세로 (768×1024)" },
|
||||
{ key: "mobile_landscape", label: "모바일 가로 (667×375)" },
|
||||
{ key: "mobile_portrait", label: "모바일 세로 (375×667)" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Label>모드별 표시 설정</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
{modes.map(({ key, label }) => {
|
||||
const isChecked = component.visibility?.[key] !== false;
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
onUpdate?.({
|
||||
visibility: {
|
||||
...component.visibility,
|
||||
[key]: e.target.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label>{label}</label>
|
||||
{!isChecked && <span>(숨김)</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 기존: 반응형 숨김 (픽셀 기반) */}
|
||||
<div className="space-y-3">
|
||||
<Label>반응형 숨김 (픽셀 기반)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.hideBelow || ""}
|
||||
onChange={(e) =>
|
||||
onUpdate?.({
|
||||
hideBelow: e.target.value ? Number(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
placeholder="없음"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
예: 500 입력 시 화면 너비가 500px 이하면 자동 숨김
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**UI 특징**:
|
||||
- 체크박스로 직관적인 표시/숨김 제어
|
||||
- 기본값은 모든 모드 체크 (표시)
|
||||
- `hideBelow` (픽셀 기반)와 별도 유지
|
||||
|
||||
---
|
||||
|
||||
### 5. 팔레트 업데이트
|
||||
|
||||
```typescript
|
||||
const COMPONENT_PALETTE = [
|
||||
// ... 기존 컴포넌트들
|
||||
{
|
||||
type: "pop-break",
|
||||
label: "줄바꿈",
|
||||
icon: WrapText,
|
||||
description: "강제 줄바꿈 (flex-basis: 100%)",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 사용 예시
|
||||
|
||||
### 예시 1: 모바일 전용 버튼
|
||||
```typescript
|
||||
{
|
||||
id: "call-button",
|
||||
type: "pop-button",
|
||||
label: "전화 걸기",
|
||||
size: { width: "fixed", height: "fixed", fixedWidth: 120, fixedHeight: 48 },
|
||||
visibility: {
|
||||
tablet_landscape: false, // 태블릿 가로: 숨김
|
||||
tablet_portrait: false, // 태블릿 세로: 숨김
|
||||
mobile_landscape: true, // 모바일 가로: 표시
|
||||
mobile_portrait: true, // 모바일 세로: 표시
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**결과**:
|
||||
- 태블릿: "전화 걸기" 버튼 안 보임
|
||||
- 모바일: "전화 걸기" 버튼 보임
|
||||
|
||||
---
|
||||
|
||||
### 예시 2: 모드별 줄바꿈
|
||||
```typescript
|
||||
레이아웃:
|
||||
[필드A] [필드B] [줄바꿈] [필드C] [필드D]
|
||||
|
||||
줄바꿈 컴포넌트 설정:
|
||||
{
|
||||
id: "break-1",
|
||||
type: "pop-break",
|
||||
visibility: {
|
||||
tablet_landscape: false, // 태블릿: 줄바꿈 숨김 (한 줄)
|
||||
mobile_portrait: true, // 모바일: 줄바꿈 표시 (두 줄)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**결과**:
|
||||
```
|
||||
태블릿 가로 (1024px):
|
||||
┌─────────────────────────────────┐
|
||||
│ [필드A] [필드B] [필드C] [필드D] │ ← 한 줄
|
||||
└─────────────────────────────────┘
|
||||
|
||||
모바일 세로 (375px):
|
||||
┌─────────────────┐
|
||||
│ [필드A] [필드B] │ ← 첫 줄
|
||||
│ [필드C] [필드D] │ ← 둘째 줄 (줄바꿈 적용)
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 예시 3: 리스트 컬럼 수 변경 (확장 가능)
|
||||
```typescript
|
||||
// 기본 (태블릿 가로)
|
||||
{
|
||||
id: "product-list",
|
||||
type: "pop-list",
|
||||
config: {
|
||||
columns: 7, // 7개 컬럼
|
||||
}
|
||||
}
|
||||
|
||||
// 오버라이드 (모바일 세로)
|
||||
overrides: {
|
||||
mobile_portrait: {
|
||||
components: {
|
||||
"product-list": {
|
||||
config: {
|
||||
columns: 3, // 3개 컬럼
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결과**:
|
||||
- 태블릿: 7개 컬럼 표시
|
||||
- 모바일: 3개 컬럼 표시 (병합됨)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 테스트 시나리오
|
||||
|
||||
### 테스트 1: 줄바꿈 기본 동작
|
||||
```
|
||||
1. 팔레트에서 "줄바꿈" 드래그
|
||||
2. [A] [B] [C] 사이에 드롭
|
||||
3. 예상 결과: [A] [B] / [C]
|
||||
4. 디자인 모드에서 점선 "줄바꿈" 표시 확인
|
||||
5. 미리보기에서 줄바꿈이 안 보이는지 확인
|
||||
```
|
||||
|
||||
### 테스트 2: 모드별 줄바꿈 표시
|
||||
```
|
||||
1. 줄바꿈 컴포넌트 추가
|
||||
2. "표시" 탭 → 태블릿 모드 체크 해제
|
||||
3. 태블릿 가로 모드: [A] [B] [C] (한 줄)
|
||||
4. 모바일 세로 모드: [A] [B] / [C] (두 줄)
|
||||
```
|
||||
|
||||
### 테스트 3: 컴포넌트 삭제 시 오버라이드 정리
|
||||
```
|
||||
1. 모바일 세로 모드에서 배치 고정
|
||||
2. 컴포넌트 삭제
|
||||
3. 저장 후 로드
|
||||
4. DB 확인: overrides에서도 제거되었는지
|
||||
```
|
||||
|
||||
### 테스트 4: 모드별 컴포넌트 숨김
|
||||
```
|
||||
1. "전화 걸기" 버튼 추가
|
||||
2. "표시" 탭 → 태블릿 모드 체크 해제
|
||||
3. 태블릿 가로: 버튼 안 보임
|
||||
4. 모바일 세로: 버튼 보임
|
||||
```
|
||||
|
||||
### 테스트 5: 속성 패널 UI
|
||||
```
|
||||
1. 컴포넌트 선택
|
||||
2. "표시" 탭 클릭
|
||||
3. 4개 체크박스 확인 (모두 체크됨)
|
||||
4. 체크 해제 시 "(숨김)" 표시 확인
|
||||
5. 저장 후 로드 → 체크 상태 유지
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 기술적 고려사항
|
||||
|
||||
### 1. 데이터 일관성
|
||||
```
|
||||
문제: 컴포넌트 삭제 시 오버라이드 잔여물
|
||||
|
||||
해결:
|
||||
- cleanupOverridesAfterDelete() 함수
|
||||
- containers.root.children 정리
|
||||
- components 오버라이드 정리
|
||||
- 빈 오버라이드 자동 삭제
|
||||
```
|
||||
|
||||
### 2. 병합 우선순위
|
||||
```
|
||||
우선순위 (높음 → 낮음):
|
||||
1. tempLayout (고정 전 미리보기)
|
||||
2. overrides[currentMode].containers.root
|
||||
3. overrides[currentMode].components[id]
|
||||
4. layout.root (기본값)
|
||||
5. layout.components[id] (기본값)
|
||||
```
|
||||
|
||||
### 3. 성능 최적화
|
||||
```typescript
|
||||
// useMemo로 병합 결과 캐싱
|
||||
const effectiveRoot = useMemo(() => getMergedRoot(), [tempLayout, overrides, currentMode]);
|
||||
const mergedComponent = useMemo(() => getMergedComponent(baseComponent), [baseComponent, overrides, currentMode]);
|
||||
```
|
||||
|
||||
### 4. 타입 안전성
|
||||
```typescript
|
||||
// visibility 키는 ViewportPreset에서만 허용
|
||||
visibility?: {
|
||||
[K in ViewportPreset]?: boolean;
|
||||
};
|
||||
|
||||
// 컴파일 타임에 오타 방지
|
||||
visibility.tablet_landspace = false; // ❌ 오타 감지!
|
||||
visibility.tablet_landscape = false; // ✅ 정상
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 영향 받는 파일
|
||||
|
||||
### 코드 파일
|
||||
```
|
||||
✅ frontend/components/pop/designer/types/pop-layout.ts
|
||||
- PopComponentType 확장 (pop-break)
|
||||
- PopComponentDefinitionV4.visibility 추가
|
||||
- cleanupOverridesAfterDelete() 추가
|
||||
|
||||
✅ frontend/components/pop/designer/renderers/PopFlexRenderer.tsx
|
||||
- isComponentVisible() 추가
|
||||
- getMergedComponent() 추가
|
||||
- pop-break 렌더링 추가
|
||||
- ContainerRenderer props 확장
|
||||
|
||||
✅ frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx
|
||||
- "표시" 탭 추가
|
||||
- VisibilityForm 컴포넌트 추가
|
||||
- COMPONENT_TYPE_LABELS 업데이트
|
||||
|
||||
✅ frontend/components/pop/designer/panels/ComponentPaletteV4.tsx
|
||||
- "줄바꿈" 컴포넌트 추가
|
||||
```
|
||||
|
||||
### 문서 파일
|
||||
```
|
||||
✅ popdocs/CHANGELOG.md
|
||||
- Phase 3 완료 기록
|
||||
|
||||
✅ popdocs/PLAN.md
|
||||
- Phase 3 체크 완료
|
||||
- Phase 4 계획 추가
|
||||
|
||||
✅ popdocs/decisions/002-phase3-visibility-break.md (이 문서)
|
||||
- 설계 결정 및 구현 상세
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
### Phase 4: 실제 컴포넌트 구현
|
||||
```
|
||||
우선순위:
|
||||
1. pop-field (입력/표시 필드)
|
||||
2. pop-button (액션 버튼)
|
||||
3. pop-list (데이터 리스트)
|
||||
4. pop-indicator (KPI 표시)
|
||||
5. pop-scanner (바코드/QR)
|
||||
6. pop-numpad (숫자 입력)
|
||||
```
|
||||
|
||||
### 추가 개선 사항
|
||||
```
|
||||
1. 컴포넌트 오버라이드 UI
|
||||
- 리스트 컬럼 수 조정
|
||||
- 버튼 스타일 변경
|
||||
- 필드 표시 형식 변경
|
||||
|
||||
2. "모든 모드에 적용" 기능
|
||||
- 한 번에 모든 모드 체크/해제
|
||||
|
||||
3. 오버라이드 비교 뷰
|
||||
- 기본값 vs 오버라이드 차이 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 결론
|
||||
|
||||
Phase 3를 통해 다음을 달성:
|
||||
1. ✅ 모드별 컴포넌트 표시/숨김 제어
|
||||
2. ✅ 강제 줄바꿈 컴포넌트 (Flexbox 한계 극복)
|
||||
3. ✅ 컴포넌트 오버라이드 병합 (확장성 확보)
|
||||
4. ✅ 데이터 일관성 유지 (삭제 시 정리)
|
||||
|
||||
이제 v4 레이아웃 시스템의 핵심 기능이 완성되었으며, 실제 컴포넌트 구현 단계로 진행할 수 있습니다.
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
# ADR-003: v5 CSS Grid 기반 그리드 시스템 채택
|
||||
|
||||
**날짜**: 2026-02-05
|
||||
**상태**: 채택됨
|
||||
**의사결정자**: 프로젝트 담당자, 상급자
|
||||
|
||||
---
|
||||
|
||||
## 배경
|
||||
|
||||
### 문제 상황
|
||||
|
||||
v4 Flexbox 기반 레이아웃으로 반응형 구현을 시도했으나 실패:
|
||||
|
||||
1. **배치 예측 불가능**: 컴포넌트가 자유롭게 움직이지만 원하는 위치에 안 감
|
||||
2. **캔버스 방식의 한계**: "그리듯이" 배치하면 화면 크기별로 깨짐
|
||||
3. **규칙 부재**: 어디에 뭘 배치해야 하는지 기준이 없음
|
||||
|
||||
### 상급자 피드백
|
||||
|
||||
> "이런 식이면 나중에 문제가 생긴다."
|
||||
>
|
||||
> "스크린의 픽셀 규격과 마진 간격 규칙을 설정해라.
|
||||
> 큰 화면 디자인의 전체 프레임 규격과 사이즈 간격 규칙을 정한 다음에
|
||||
> 거기에 컴포넌트를 끼워 맞추듯 우리의 규칙 내로 움직이게 바탕을 잡아라."
|
||||
|
||||
### 연구 내용
|
||||
|
||||
| 도구 | 핵심 특징 | 적용 가능 요소 |
|
||||
|------|----------|---------------|
|
||||
| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 |
|
||||
| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 |
|
||||
| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 |
|
||||
|
||||
---
|
||||
|
||||
## 결정
|
||||
|
||||
**CSS Grid 기반 그리드 시스템 (v5) 채택**
|
||||
|
||||
### 핵심 규칙
|
||||
|
||||
| 모드 | 화면 너비 | 칸 수 | 대상 디바이스 |
|
||||
|------|----------|-------|--------------|
|
||||
| mobile_portrait | ~599px | 4칸 | 4~6인치 모바일 |
|
||||
| mobile_landscape | 600~839px | 6칸 | 7인치 모바일 |
|
||||
| tablet_portrait | 840~1023px | 8칸 | 8~10인치 태블릿 |
|
||||
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 (기본) |
|
||||
|
||||
### 컴포넌트 배치
|
||||
|
||||
```typescript
|
||||
interface PopGridPosition {
|
||||
col: number; // 시작 열 (1부터)
|
||||
row: number; // 시작 행 (1부터)
|
||||
colSpan: number; // 열 크기 (1~12)
|
||||
rowSpan: number; // 행 크기 (1~)
|
||||
}
|
||||
```
|
||||
|
||||
### v4 대비 변경점
|
||||
|
||||
| 항목 | v4 (Flexbox) | v5 (Grid) |
|
||||
|------|-------------|-----------|
|
||||
| 배치 방식 | 흐름 기반 (자동) | 좌표 기반 (명시적) |
|
||||
| 크기 단위 | 픽셀 (200px) | 칸 (colSpan: 3) |
|
||||
| 예측성 | 낮음 | 높음 |
|
||||
| 반응형 | 복잡한 규칙 | 칸 수 변환 |
|
||||
|
||||
---
|
||||
|
||||
## 대안 검토
|
||||
|
||||
### A. v4 Flexbox 유지 (기각)
|
||||
|
||||
- **장점**: 기존 코드 활용 가능
|
||||
- **단점**: 상급자 지적한 문제 해결 안됨 (규칙 부재)
|
||||
- **결과**: 기각
|
||||
|
||||
### B. 자유 배치 (절대 좌표) (기각)
|
||||
|
||||
- **장점**: 완전한 자유도
|
||||
- **단점**: 반응형 불가능, 화면별로 전부 다시 배치 필요
|
||||
- **결과**: 기각
|
||||
|
||||
### C. CSS Grid 그리드 시스템 (채택)
|
||||
|
||||
- **장점**:
|
||||
- 규칙 기반으로 예측 가능
|
||||
- 반응형 자동화 (12칸 → 4칸 변환)
|
||||
- Material Design 표준 준수
|
||||
- **단점**:
|
||||
- 기존 v4 데이터 호환 불가
|
||||
- 자유도 제한 (칸 단위로만)
|
||||
- **결과**: **채택**
|
||||
|
||||
---
|
||||
|
||||
## 영향
|
||||
|
||||
### 변경 필요
|
||||
|
||||
- [x] 타입 정의 (`PopLayoutDataV5`, `PopGridPosition`)
|
||||
- [x] 렌더러 (`PopRenderer.tsx` - CSS Grid)
|
||||
- [x] 캔버스 (`PopCanvas.tsx` - 그리드 표시)
|
||||
- [x] 유틸리티 (`gridUtils.ts` - 좌표 계산)
|
||||
- [x] 레거시 삭제 (v1~v4 코드, 데이터)
|
||||
|
||||
### 호환성
|
||||
|
||||
- v1~v4 레이아웃: **삭제** (마이그레이션 없이 초기화)
|
||||
- 새 화면: v5로만 생성
|
||||
|
||||
### 제한 사항
|
||||
|
||||
- 컴포넌트는 칸 단위로만 배치 (칸 사이 배치 불가)
|
||||
- 12칸 기준으로 설계 후 다른 모드는 자동 변환
|
||||
|
||||
---
|
||||
|
||||
## 교훈
|
||||
|
||||
1. **규칙이 자유를 만든다**: 제약이 있어야 일관된 디자인 가능
|
||||
2. **상급자 피드백 중요**: "프레임 규격 먼저" 조언이 핵심 방향 제시
|
||||
3. **연구 후 결정**: Softr, Ant Design 분석이 구체적 방향 제시
|
||||
4. **과감한 삭제**: 레거시 유지보다 깔끔한 재시작이 나음
|
||||
|
||||
---
|
||||
|
||||
## 참조
|
||||
|
||||
- Softr: https://www.softr.io
|
||||
- Ant Design Grid: https://ant.design/components/grid
|
||||
- Material Design Layout: https://m3.material.io/foundations/layout
|
||||
- GRID_SYSTEM_DESIGN.md: 상세 설계 스펙
|
||||
|
||||
---
|
||||
|
||||
## 관련
|
||||
|
||||
- [ADR-001](./001-v4-constraint-based.md): v4 제약조건 기반 (이전 시도)
|
||||
- [CHANGELOG 2026-02-05](../CHANGELOG.md#2026-02-05): 작업 내역
|
||||
- [sessions/2026-02-05](../sessions/2026-02-05.md): 대화 기록
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
# ADR-004: 그리드 가이드 CSS Grid 통합
|
||||
|
||||
**상태**: 승인됨
|
||||
**날짜**: 2026-02-05
|
||||
**결정자**: 개발팀
|
||||
|
||||
---
|
||||
|
||||
## 컨텍스트
|
||||
|
||||
그리드 가이드는 다음 목적을 가짐:
|
||||
1. **시각적 기준**: 어디에 배치할지 눈으로 확인 가능
|
||||
2. **정렬 도움**: 칸에 맞춰 배치하기 쉬움
|
||||
3. **디자인 일관성**: 규칙적인 배치 유도
|
||||
|
||||
기존 구현:
|
||||
- `GridGuide.tsx`: SVG `<line>` 요소로 격자선 렌더링
|
||||
- `PopRenderer.tsx`: CSS Grid로 컴포넌트 배치
|
||||
|
||||
---
|
||||
|
||||
## 문제
|
||||
|
||||
### 좌표계 불일치
|
||||
```
|
||||
SVG 좌표: 픽셀 기반 (0, 0) ~ (width, height)
|
||||
CSS Grid 좌표: 칸 기반 (col 1~12, row 1~20)
|
||||
|
||||
→ 두 좌표계를 정확히 동기화하기 어려움
|
||||
→ 격자선과 컴포넌트가 정렬되지 않음 ("무늬가 따로 논다")
|
||||
```
|
||||
|
||||
### 구체적 증상
|
||||
1. GridGuide의 행/열 라벨이 4부터 시작 (잘못된 계산)
|
||||
2. 격자선 위치와 실제 CSS Grid 셀 위치 불일치
|
||||
3. 줌/패닝 시 두 레이어가 다르게 동작
|
||||
|
||||
---
|
||||
|
||||
## 결정
|
||||
|
||||
**GridGuide.tsx를 삭제하고, PopRenderer.tsx에서 CSS Grid 기반으로 격자를 직접 렌더링한다.**
|
||||
|
||||
핵심 원칙:
|
||||
> "격자선은 컴포넌트와 같은 좌표계에서 태어나야 한다"
|
||||
|
||||
---
|
||||
|
||||
## 대안 검토
|
||||
|
||||
### Option A: SVG 계산 수정
|
||||
- **방법**: GridGuide의 좌표 계산을 정확히 수정
|
||||
- **장점**: 기존 코드 활용
|
||||
- **단점**: 근본적으로 두 좌표계가 다름, 유지보수 어려움
|
||||
- **결정**: 채택 안 함
|
||||
|
||||
### Option B: PopRenderer에 CSS 배경 격자
|
||||
- **방법**: `background-image: linear-gradient()`로 격자 표현
|
||||
- **장점**: 구현 간단
|
||||
- **단점**: 라벨 표시 불가, 셀 단위 상호작용 불가
|
||||
- **결정**: 채택 안 함
|
||||
|
||||
### Option C: CSS Grid 셀로 격자 렌더링 (채택)
|
||||
- **방법**: 실제 `div` 요소를 12x20 = 240개 생성, CSS Grid로 배치
|
||||
- **장점**:
|
||||
- 컴포넌트와 100% 동일한 좌표계
|
||||
- 셀 단위 hover, 클릭 등 상호작용 가능
|
||||
- 라벨은 캔버스 외부에 별도 렌더링
|
||||
- **단점**: DOM 요소 증가 (240개)
|
||||
- **결정**: 채택
|
||||
|
||||
---
|
||||
|
||||
## 구현 상세
|
||||
|
||||
### 역할 분담
|
||||
|
||||
| 컴포넌트 | 역할 | 좌표계 |
|
||||
|----------|------|--------|
|
||||
| PopRenderer | 격자 셀 + 컴포넌트 | CSS Grid |
|
||||
| PopCanvas | 라벨 + 줌/패닝 + 토글 | absolute |
|
||||
| GridGuide | (삭제) | - |
|
||||
|
||||
### PopRenderer 변경
|
||||
|
||||
```typescript
|
||||
// gridCells 생성 (useMemo)
|
||||
const gridCells = useMemo(() => {
|
||||
const cells = [];
|
||||
for (let row = 1; row <= 20; row++) {
|
||||
for (let col = 1; col <= 12; col++) {
|
||||
cells.push({ id: `${col}-${row}`, col, row });
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}, []);
|
||||
|
||||
// 렌더링
|
||||
{showGridGuide && gridCells.map(cell => (
|
||||
<div
|
||||
key={cell.id}
|
||||
className="border border-dashed border-blue-300/40"
|
||||
style={{
|
||||
gridColumn: cell.col,
|
||||
gridRow: cell.row,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
```
|
||||
|
||||
### PopCanvas 라벨 구조
|
||||
|
||||
```
|
||||
[1] [2] [3] [4] [5] [6] [7] [8] [9] [10][11][12] ← 열 라벨 (캔버스 상단)
|
||||
┌───────────────────────────────────────────┐
|
||||
[1] │ │ │ │ │ │ │ │ │ │ │ │
|
||||
[2] │ │ │ │ │ │ │ │ │ │ │ │
|
||||
[3] │ │ │ │ ■ │ │ │ │ │ │ │ │ ← 5열 3행
|
||||
└───────────────────────────────────────────┘
|
||||
↑ 행 라벨 (캔버스 좌측)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 결과
|
||||
|
||||
### 기대 효과
|
||||
1. 격자선과 컴포넌트 100% 정렬
|
||||
2. 정확한 행/열 번호 표시 (1부터 시작)
|
||||
3. 줌/패닝 시 일관된 동작
|
||||
4. 향후 셀 클릭으로 빠른 배치 기능 확장 가능
|
||||
|
||||
### 트레이드오프
|
||||
- DOM 요소 240개 추가 (성능 영향 미미)
|
||||
- GridGuide 코드 삭제 필요
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- 문제: [PROBLEMS.md](../PROBLEMS.md) > P004
|
||||
- 변경: [CHANGELOG.md](../CHANGELOG.md) > 2026-02-05 오후
|
||||
- 세션: [sessions/2026-02-05.md](../sessions/2026-02-05.md)
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
# ADR 005: 브레이크포인트 재설계 (기기 기반)
|
||||
|
||||
**날짜**: 2026-02-06
|
||||
**상태**: 채택
|
||||
**의사결정자**: 시스템 아키텍트
|
||||
|
||||
---
|
||||
|
||||
## 상황 (Context)
|
||||
|
||||
### 문제 1: 뷰어에서 모드 전환 불일치
|
||||
|
||||
```
|
||||
브라우저 수동 리사이즈 시:
|
||||
- useResponsiveMode 훅: 768px 이상 → "tablet" 판정
|
||||
- GRID_BREAKPOINTS: 768~839px → "mobile_landscape" (6칸)
|
||||
|
||||
결과: 768~839px 구간에서 모드 불일치 발생
|
||||
```
|
||||
|
||||
### 문제 2: 기존 브레이크포인트 근거 부족
|
||||
|
||||
```
|
||||
기존 설정:
|
||||
- mobile_portrait: ~599px
|
||||
- mobile_landscape: 600~839px
|
||||
- tablet_portrait: 840~1023px
|
||||
|
||||
문제: 실제 기기 뷰포트와 맞지 않음
|
||||
- iPad Mini 세로: 768px (mobile_landscape로 분류됨)
|
||||
```
|
||||
|
||||
### 사용자 요구사항
|
||||
|
||||
> "현장 모바일 기기가 최소 8인치 ~ 최대 14인치,
|
||||
> 핸드폰은 아이폰 미니 ~ 갤럭시 울트라 사이즈"
|
||||
|
||||
---
|
||||
|
||||
## 연구 (Research)
|
||||
|
||||
### 실제 기기 CSS 뷰포트 조사 (2026년 기준)
|
||||
|
||||
| 기기 | 화면 크기 | CSS 뷰포트 너비 |
|
||||
|------|----------|----------------|
|
||||
| iPhone SE | 4.7" | 375px |
|
||||
| iPhone 16 Pro | 6.3" | 402px |
|
||||
| Galaxy S25 Ultra | 6.9" | 440px |
|
||||
| iPad Mini 7 | 8.3" | 768px |
|
||||
| iPad Pro 11 | 11" | 834px (세로), 1194px (가로) |
|
||||
| iPad Pro 13 | 13" | 1024px (세로), 1366px (가로) |
|
||||
|
||||
### 업계 표준 브레이크포인트
|
||||
|
||||
| 프레임워크 | 모바일/태블릿 경계 | 태블릿/데스크톱 경계 |
|
||||
|-----------|------------------|-------------------|
|
||||
| Tailwind CSS | 768px | 1024px |
|
||||
| Bootstrap 5 | 768px | 992px |
|
||||
| Material Design 3 | 600px | 840px |
|
||||
|
||||
**공통점**: 768px, 1024px가 거의 표준
|
||||
|
||||
---
|
||||
|
||||
## 결정 (Decision)
|
||||
|
||||
### 채택: 기기 기반 브레이크포인트
|
||||
|
||||
| 모드 | 너비 범위 | 변경 전 | 근거 |
|
||||
|------|----------|--------|------|
|
||||
| mobile_portrait | 0~479px | 0~599px | 스마트폰 세로 최대 440px |
|
||||
| mobile_landscape | 480~767px | 600~839px | 스마트폰 가로, 767px까지 |
|
||||
| tablet_portrait | 768~1023px | 840~1023px | iPad Mini 768px 포함 |
|
||||
| tablet_landscape | 1024px+ | 동일 | 대형 태블릿 가로 |
|
||||
|
||||
### 핵심 변경
|
||||
|
||||
```typescript
|
||||
// pop-layout.ts - GRID_BREAKPOINTS
|
||||
mobile_portrait: { maxWidth: 479 } // was 599
|
||||
mobile_landscape: { minWidth: 480, maxWidth: 767 } // was 600, 839
|
||||
tablet_portrait: { minWidth: 768, maxWidth: 1023 } // was 840, 1023
|
||||
tablet_landscape: { minWidth: 1024 } // 동일
|
||||
|
||||
// pop-layout.ts - detectGridMode()
|
||||
if (viewportWidth < 480) return "mobile_portrait"; // was 600
|
||||
if (viewportWidth < 768) return "mobile_landscape"; // was 840
|
||||
if (viewportWidth < 1024) return "tablet_portrait";
|
||||
|
||||
// useDeviceOrientation.ts - BREAKPOINTS
|
||||
TABLET_MIN: 768 // was 840
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 (Implementation)
|
||||
|
||||
### 수정 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `pop-layout.ts` | GRID_BREAKPOINTS 값 수정, detectGridMode() 조건 수정 |
|
||||
| `useDeviceOrientation.ts` | BREAKPOINTS.TABLET_MIN = 768 |
|
||||
| `PopCanvas.tsx` | VIEWPORT_PRESETS width 값 조정 |
|
||||
| `page.tsx (뷰어)` | detectGridMode() 사용으로 일관성 확보 |
|
||||
|
||||
### 뷰어 모드 감지 방식 변경
|
||||
|
||||
```typescript
|
||||
// 변경 전: useResponsiveModeWithOverride만 사용
|
||||
const currentModeKey = getModeKey(deviceType, isLandscape);
|
||||
|
||||
// 변경 후: 프리뷰 모드와 일반 모드 분리
|
||||
const currentModeKey = isPreviewMode
|
||||
? getModeKey(deviceType, isLandscape) // 프리뷰: 수동 선택
|
||||
: detectGridMode(viewportWidth); // 일반: 너비 기반
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 결과 (Consequences)
|
||||
|
||||
### 긍정적 효과
|
||||
|
||||
| 효과 | 설명 |
|
||||
|------|------|
|
||||
| **기기 커버리지** | 아이폰 SE ~ 갤럭시 울트라, 8~14인치 태블릿 모두 포함 |
|
||||
| **업계 표준 호환** | 768px, 1024px는 거의 모든 프레임워크 기준점 |
|
||||
| **일관성 확보** | GRID_BREAKPOINTS와 detectGridMode() 완전 일치 |
|
||||
| **직관적 매핑** | 스마트폰 세로/가로, 태블릿 세로/가로 자연스럽게 분류 |
|
||||
|
||||
### 트레이드오프
|
||||
|
||||
| 항목 | 설명 |
|
||||
|------|------|
|
||||
| **기존 데이터 영향** | 600~767px 구간이 6칸→6칸 (영향 없음) |
|
||||
| **768~839px 변경** | 기존 6칸→8칸 (태블릿으로 재분류) |
|
||||
|
||||
---
|
||||
|
||||
## 세로 자동 확장 (추가 결정)
|
||||
|
||||
### 배경
|
||||
|
||||
> "세로는 신경쓸 필요가 없는 것 맞지?
|
||||
> 그렇다면 캔버스도 세로 무한 스크롤이 가능해야겠네?"
|
||||
|
||||
### 결정
|
||||
|
||||
1. **뷰포트 프리셋에서 height 제거** (width만 유지)
|
||||
2. **캔버스 높이 동적 계산** (컴포넌트 배치 기준)
|
||||
3. **항상 여유 행 3개 유지** (추가 배치 공간)
|
||||
4. **뷰어에서 터치 스크롤** 지원
|
||||
|
||||
### 구현
|
||||
|
||||
```typescript
|
||||
// PopCanvas.tsx
|
||||
const MIN_CANVAS_HEIGHT = 600;
|
||||
const CANVAS_EXTRA_ROWS = 3;
|
||||
|
||||
const dynamicCanvasHeight = useMemo(() => {
|
||||
const maxRowEnd = visibleComps.reduce((max, comp) => {
|
||||
return Math.max(max, comp.row + comp.rowSpan);
|
||||
}, 1);
|
||||
|
||||
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
|
||||
return Math.max(MIN_CANVAS_HEIGHT, totalRows * rowHeight);
|
||||
}, [layout.components, ...]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [003-v5-grid-system.md](./003-v5-grid-system.md) - v5 그리드 시스템 채택
|
||||
- [006-auto-wrap-review-system.md](./006-auto-wrap-review-system.md) - 자동 줄바꿈
|
||||
|
||||
---
|
||||
|
||||
**결론**: 실제 기기 뷰포트 기반 브레이크포인트로 일관성 확보 + 세로 무한 스크롤로 UX 개선
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
# ADR 006: v5.1 자동 줄바꿈 + 검토 필요 시스템
|
||||
|
||||
**날짜**: 2026-02-06
|
||||
**상태**: 채택
|
||||
**의사결정자**: 시스템 아키텍트
|
||||
|
||||
---
|
||||
|
||||
## 상황 (Context)
|
||||
|
||||
v5 반응형 레이아웃에서 "화면 밖" 개념으로 컴포넌트를 처리했으나, 다음 문제가 발생했습니다:
|
||||
|
||||
### 문제 1: 정보 손실
|
||||
```
|
||||
12칸 모드:
|
||||
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
|
||||
│ A │ B (col=5, 6칸) │
|
||||
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
|
||||
|
||||
4칸 모드 (기존):
|
||||
┌────┬────┬────┬────┐ 화면 밖:
|
||||
│ A │ │ - B
|
||||
└────┴────┴────┴────┘
|
||||
↑ A만 보임 ↑ 뷰어에서 안 보임!
|
||||
```
|
||||
|
||||
### 문제 2: 사용자 의도 불일치
|
||||
사용자가 기대한 "화면 밖" 역할:
|
||||
- ❌ 컴포넌트 숨김 (현재 동작)
|
||||
- ✅ "이 컴포넌트 검토 필요" 알림
|
||||
|
||||
---
|
||||
|
||||
## 결정 (Decision)
|
||||
|
||||
### 채택: 자동 줄바꿈 + 검토 필요 시스템
|
||||
|
||||
```
|
||||
col > maxCol → 자동으로 맨 아래에 배치 (줄바꿈)
|
||||
오버라이드 없음 → "검토 필요" 알림
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 (Implementation)
|
||||
|
||||
### 1. 자동 줄바꿈 로직
|
||||
|
||||
**파일**: `gridUtils.ts` - `convertAndResolvePositions()`
|
||||
|
||||
```typescript
|
||||
// 단계별 처리:
|
||||
1. 비율 변환 + 원본 col 보존
|
||||
converted = components.map(comp => ({
|
||||
id: comp.id,
|
||||
position: convertPositionToMode(comp.position, targetMode),
|
||||
originalCol: comp.position.col, // ⭐ 원본 보존
|
||||
}))
|
||||
|
||||
2. 정상 vs 초과 분리
|
||||
normalComponents = originalCol ≤ targetColumns
|
||||
overflowComponents = originalCol > targetColumns
|
||||
|
||||
3. 초과 컴포넌트 자동 배치
|
||||
maxRow = normalComponents의 최대 row
|
||||
overflowComponents → col=1, row=맨아래+1
|
||||
|
||||
4. 겹침 해결
|
||||
resolveOverlaps([...normalComponents, ...wrappedComponents])
|
||||
```
|
||||
|
||||
### 2. 검토 필요 판별
|
||||
|
||||
**파일**: `gridUtils.ts` - `needsReview()`
|
||||
|
||||
```typescript
|
||||
function needsReview(
|
||||
currentMode: GridMode,
|
||||
hasOverride: boolean
|
||||
): boolean {
|
||||
// 12칸 모드는 기본 모드이므로 검토 불필요
|
||||
if (GRID_BREAKPOINTS[currentMode].columns === 12) return false;
|
||||
|
||||
// 오버라이드가 있으면 이미 편집함 → 검토 완료
|
||||
if (hasOverride) return false;
|
||||
|
||||
// 오버라이드 없으면 → 검토 필요
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
**판단 기준 (최종)**: "이 모드에서 편집했냐 안 했냐"
|
||||
|
||||
### 3. 검토 필요 패널
|
||||
|
||||
**파일**: `PopCanvas.tsx` - `ReviewPanel`
|
||||
|
||||
```typescript
|
||||
// 필터링
|
||||
const reviewComponents = visibleComponents.filter(comp => {
|
||||
const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id];
|
||||
return needsReview(currentMode, hasOverride);
|
||||
});
|
||||
|
||||
// UI
|
||||
<ReviewPanel
|
||||
components={reviewComponents}
|
||||
onSelectComponent={onSelectComponent} // 클릭 시 선택
|
||||
/>
|
||||
```
|
||||
|
||||
**변경 사항**:
|
||||
- 기존: `OutOfBoundsPanel` (주황색, 드래그로 복원)
|
||||
- 변경: `ReviewPanel` (파란색, 클릭으로 선택)
|
||||
|
||||
---
|
||||
|
||||
## 결과 (Consequences)
|
||||
|
||||
### 긍정적 효과
|
||||
|
||||
| 효과 | 설명 |
|
||||
|------|------|
|
||||
| **정보 손실 방지** | 모든 컴포넌트가 항상 그리드 안에 표시됨 |
|
||||
| **사용자 부담 감소** | 자동 배치를 먼저 제공, 필요시에만 편집 |
|
||||
| **의도 명확화** | "숨김" ≠ "검토 필요" (기능 분리) |
|
||||
| **뷰어 호환** | 자동 배치가 뷰어에도 적용됨 |
|
||||
|
||||
### 트레이드오프
|
||||
|
||||
| 항목 | 설명 |
|
||||
|------|------|
|
||||
| **스크롤 증가** | 아래로 자동 배치되면 페이지가 길어질 수 있음 |
|
||||
| **자동 배치 품질** | 사용자가 원하지 않는 위치에 배치될 수 있음 |
|
||||
|
||||
---
|
||||
|
||||
## 사용자 시나리오
|
||||
|
||||
### 시나리오 1: 수용 (자동 배치 그대로)
|
||||
```
|
||||
1. 12칸에서 컴포넌트 A, B, C 배치
|
||||
2. 4칸 모드로 전환
|
||||
3. 시스템: 자동 배치 + "검토 필요 (3개)" 알림
|
||||
4. 사용자: 확인 → "괜찮네" → 아무것도 안 함
|
||||
5. 결과: 자동 배치 유지 (오버라이드 없음)
|
||||
```
|
||||
|
||||
### 시나리오 2: 편집 (오버라이드 저장)
|
||||
```
|
||||
1. 12칸에서 컴포넌트 A, B, C 배치
|
||||
2. 4칸 모드로 전환
|
||||
3. 시스템: 자동 배치 + "검토 필요 (3개)" 알림
|
||||
4. 사용자: A 클릭 → 드래그/리사이즈
|
||||
5. 결과: A 오버라이드 저장 → A 검토 완료
|
||||
6. "검토 필요 (2개)" (B, C만 남음)
|
||||
```
|
||||
|
||||
### 시나리오 3: 보류 (나중에)
|
||||
```
|
||||
1. 12칸에서 컴포넌트 A, B, C 배치
|
||||
2. 4칸 모드로 전환
|
||||
3. 시스템: 자동 배치 + "검토 필요 (3개)" 알림
|
||||
4. 사용자: 다른 모드로 전환 또는 저장
|
||||
5. 결과: 자동 배치 유지, 나중에도 "검토 필요" 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 기능 비교
|
||||
|
||||
| 구분 | 역할 | 뷰어에서 | 판단 기준 |
|
||||
|------|------|---------|----------|
|
||||
| **검토 필요** | 자동 배치 알림 | **보임** | 오버라이드 없음 |
|
||||
| **숨김** | 의도적 숨김 | **안 보임** | hidden 배열에 ID |
|
||||
|
||||
---
|
||||
|
||||
## 대안 (Alternatives Considered)
|
||||
|
||||
### A안: 완전 자동 (채택 ✅)
|
||||
- 모든 초과 컴포넌트 자동 배치
|
||||
- "검토 필요" 알림으로 확인 유도
|
||||
- 업계 표준 (Webflow, Retool)
|
||||
|
||||
### B안: 선택적 자동 (미채택)
|
||||
- 첫 전환 시만 자동 배치
|
||||
- 사용자가 원하면 "화면 밖"으로 드래그
|
||||
- 복잡성 증가
|
||||
|
||||
### C안: 수동 배치 유지 (미채택)
|
||||
- 기존 "화면 밖" 패널 유지
|
||||
- 사용자가 모든 모드 수동 편집
|
||||
- 사용자 부담 과다
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
### 업계 표준 (2026년 기준)
|
||||
- **Grafana, Tableau**: Masonry Layout (조적식)
|
||||
- **Retool, PowerApps**: Vertical Stacking (수직 스택)
|
||||
- **Webflow, Framer**: CSS Grid Auto-Placement
|
||||
|
||||
**공통점**: "Fluid Reflow (유동적 재배치)" - 정보 손실 방지
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `gridUtils.ts` | convertAndResolvePositions, needsReview 추가 |
|
||||
| `PopCanvas.tsx` | ReviewPanel로 변경 |
|
||||
| `PopRenderer.tsx` | isOutOfBounds import 제거 |
|
||||
| `pop-layout.ts` | 타입 변경 없음 (기존 구조 유지) |
|
||||
|
||||
---
|
||||
|
||||
**결론**: 자동 줄바꿈 + 검토 필요 시스템으로 정보 손실 방지 및 사용자 부담 최소화
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
# 2026-02-05 작업 기록
|
||||
|
||||
## 요약
|
||||
v5 그리드 시스템 통합 완료, 그리드 가이드 재설계, **드래그앤드롭 좌표 버그 수정**, popdocs 문서 구조 재정비
|
||||
|
||||
---
|
||||
|
||||
## 완료
|
||||
|
||||
### 드래그앤드롭 완전 수정 (저녁)
|
||||
- [x] 스케일 보정 누락 문제 해결
|
||||
- [x] calcGridPosition 함수 추가
|
||||
- [x] DND 타입 상수 통합 (constants/dnd.ts)
|
||||
- [x] 불필요한 toast 메시지 제거
|
||||
- [x] 컴포넌트 이동/리사이즈 정상 작동 확인
|
||||
- [x] **컴포넌트 중첩(겹침) 문제 해결** - toast import 누락 수정
|
||||
- [x] **리사이즈 핸들 작동 문제 해결** - useDrop 훅 통합
|
||||
|
||||
### v5 통합 작업
|
||||
- [x] 레거시 파일 삭제 (PopCanvasV4, PopFlexRenderer, PopLayoutRenderer 등)
|
||||
- [x] 파일명 정규화 (V5 접미사 제거)
|
||||
- [x] 뷰어 페이지 v5 전용으로 업데이트
|
||||
- [x] 백엔드 screenManagementService v5 전용 단순화
|
||||
- [x] DB 기존 레이아웃 데이터 삭제
|
||||
|
||||
### 문서 재정비 작업
|
||||
- [x] SAVE_RULES.md 생성 (AI 저장/조회 규칙)
|
||||
- [x] README.md 재작성 (진입점 역할)
|
||||
- [x] STATUS.md 생성 (현재 상태)
|
||||
- [x] PROBLEMS.md 생성 (문제-해결 색인)
|
||||
- [x] INDEX.md 생성 (기능별 색인)
|
||||
- [x] sessions/ 폴더 구조 도입
|
||||
|
||||
### 디자이너 완성 작업
|
||||
- [x] 컴포넌트 팔레트 UI 추가 (ComponentPalette.tsx)
|
||||
- [x] PopCanvas.tsx 타입 오류 수정
|
||||
- [x] 드래그앤드롭 연결
|
||||
|
||||
### 그리드 가이드 재설계
|
||||
- [x] GridGuide.tsx 삭제 (SVG 기반 → 좌표 불일치 문제)
|
||||
- [x] PopRenderer.tsx 격자 셀 렌더링 (CSS Grid 기반, 동일 좌표계)
|
||||
- [x] PopCanvas.tsx 행/열 라벨 추가 (캔버스 바깥)
|
||||
- [x] 컴포넌트 타입 단순화 (pop-sample 1개)
|
||||
|
||||
### 기반 정리 작업
|
||||
- [x] pop-layout.ts: PopComponentType을 pop-sample 1개로 단순화
|
||||
- [x] ComponentPalette.tsx: 샘플 박스 1개만 표시
|
||||
- [x] PopRenderer.tsx: 샘플 박스 렌더링으로 단순화
|
||||
|
||||
---
|
||||
|
||||
## 미완료
|
||||
|
||||
- [x] 실제 화면 테스트 (디자이너 페이지) → 완료, 정상 작동
|
||||
- [x] 간격 조정 규칙 결정 → 2026-02-06 Gap 프리셋으로 해결 (좁게/보통/넓게)
|
||||
|
||||
---
|
||||
|
||||
## 그리드 가이드 재설계 상세
|
||||
|
||||
### 문제 원인
|
||||
1. GridGuide.tsx가 SVG로 별도 렌더링 → CSS Grid 기반 컴포넌트와 좌표계 불일치
|
||||
2. PopRenderer의 그리드 배경이 희미 (rgba 0.2)
|
||||
3. 행/열 번호 라벨 없음
|
||||
|
||||
### 해결 방안 (Option C 하이브리드)
|
||||
```
|
||||
역할 분담:
|
||||
- PopRenderer: 격자선 + 컴포넌트 (같은 좌표계)
|
||||
- PopCanvas: 라벨 + 줌/패닝 + 드롭존
|
||||
- GridGuide: 삭제
|
||||
```
|
||||
|
||||
### 핵심 설계
|
||||
```
|
||||
SVG 격자 (별도 좌표) → CSS Grid 셀 (동일 좌표)
|
||||
- gridCells: 12열 × 20행 = 240개 실제 DOM 셀
|
||||
- border-dashed border-blue-300/40 스타일
|
||||
- 컴포넌트는 z-index:10으로 위에 표시
|
||||
```
|
||||
|
||||
### 라벨 구조
|
||||
```
|
||||
[1] [2] [3] [4] [5] [6] [7] [8] [9] [10][11][12] ← 열 라벨 (캔버스 상단)
|
||||
┌───────────────────────────────────────────┐
|
||||
[1] │ │ │ │ │ │ │ │ │ │ │ │
|
||||
[2] │ │ │ │ │ │ │ │ │ │ │ │
|
||||
[3] │ │ │ │ ■ │ │ │ │ │ │ │ │ ← 5열 3행
|
||||
└───────────────────────────────────────────┘
|
||||
↑ 행 라벨 (캔버스 좌측)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 대화 핵심
|
||||
|
||||
### v5 전환 배경
|
||||
- **문제**: v4 Flexbox로 반응형 시도 → 배치 예측 불가능
|
||||
- **상급자 피드백**: "스크린 규격과 마진 간격 규칙을 먼저 정해라"
|
||||
- **연구**: Softr, Ant Design, Material Design 분석
|
||||
- **결정**: CSS Grid 기반 그리드 시스템 채택
|
||||
|
||||
### 그리드 가이드 재설계 배경
|
||||
- **문제**: SVG GridGuide와 CSS Grid PopRenderer가 좌표계 불일치
|
||||
- **원칙**: "격자선은 컴포넌트와 같은 좌표계에서 태어나야 한다"
|
||||
- **결정**: CSS Grid 기반 실제 DOM 셀로 격자 렌더링
|
||||
|
||||
### popdocs 재정비 배경
|
||||
- **문제**: 문서 구조가 AI 에이전트 진입점 역할 못함
|
||||
- **해결**: Progressive Disclosure 적용, 저장/조회 규칙 명시화
|
||||
- **참고**: 2025-2026 AI 컨텍스트 엔지니어링 최신 기법
|
||||
|
||||
---
|
||||
|
||||
## 빌드 결과
|
||||
|
||||
```
|
||||
exit_code: 0
|
||||
popScreenMngList: 29.4 kB (311 KB First Load)
|
||||
총 변경: 8,453줄 삭제, 1,819줄 추가 (순감 6,634줄)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 링크
|
||||
|
||||
- ADR: [decisions/003-v5-grid-system.md](../decisions/003-v5-grid-system.md)
|
||||
- 삭제된 파일 목록: FILES.md 하단 "삭제된 파일" 섹션
|
||||
|
||||
---
|
||||
|
||||
## 드래그앤드롭 좌표 버그 수정 상세
|
||||
|
||||
### 문제 현상
|
||||
- 컴포넌트를 아래로 드래그해도 위로 올라감
|
||||
- Row 92 같은 비정상적인 좌표로 배치됨
|
||||
- 드래그 이동/리사이즈가 전혀 작동하지 않음
|
||||
|
||||
### 핵심 원인
|
||||
캔버스에 `transform: scale(0.8)` 적용 시 좌표 계산 불일치:
|
||||
```
|
||||
getBoundingClientRect() → 스케일 적용된 크기 (1024px → 819px)
|
||||
getClientOffset() → 뷰포트 기준 실제 마우스 좌표
|
||||
이 둘을 그대로 계산하면 좌표가 완전히 틀림
|
||||
```
|
||||
|
||||
### 해결 방법
|
||||
단순한 상대 좌표 + 스케일 보정:
|
||||
```typescript
|
||||
// 캔버스 내 상대 좌표 (스케일 보정)
|
||||
const relX = (offset.x - canvasRect.left) / canvasScale;
|
||||
const relY = (offset.y - canvasRect.top) / canvasScale;
|
||||
|
||||
// 그리드 좌표 계산 (실제 캔버스 크기 사용)
|
||||
calcGridPosition(relX, relY, customWidth, breakpoint.columns, ...);
|
||||
```
|
||||
|
||||
### 추가 수정
|
||||
- DND 타입 상수를 3개 파일에서 중복 정의 → `constants/dnd.ts`로 통합
|
||||
- 불필요한 "컴포넌트가 이동되었습니다" toast 메시지 제거
|
||||
|
||||
---
|
||||
|
||||
## 다음 작업자 참고
|
||||
|
||||
1. **테스트 완료**
|
||||
- 디자이너 페이지에서 그리드 가이드 확인 ✅
|
||||
- 컴포넌트 드래그앤드롭 테스트 ✅
|
||||
- 4가지 모드 전환 테스트 (추가 확인 필요)
|
||||
|
||||
2. **향후 결정 필요**
|
||||
- 간격 조정: 전역 고정 vs 화면별 vs 컴포넌트별
|
||||
- 행 수: 현재 20행 고정, 동적 변경 여부
|
||||
|
||||
3. **Phase 4 준비**
|
||||
- 실제 컴포넌트 구현 (pop-label, pop-button 등)
|
||||
- 데이터 바인딩 연결
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
# 2026-02-06 작업 기록
|
||||
|
||||
## 요약
|
||||
v5.1 자동 줄바꿈 + 검토 필요 시스템 완성, 브레이크포인트 재설계, 세로 자동 확장 구현
|
||||
|
||||
---
|
||||
|
||||
## 완료
|
||||
|
||||
### 브레이크포인트 재설계
|
||||
- [x] GRID_BREAKPOINTS 값 수정 (기기 기반)
|
||||
- [x] detectGridMode() 조건 수정
|
||||
- [x] useDeviceOrientation.ts TABLET_MIN 768로 변경
|
||||
- [x] 뷰어에서 detectGridMode() 사용하여 일관성 확보
|
||||
|
||||
### 세로 자동 확장
|
||||
- [x] VIEWPORT_PRESETS에서 height 속성 제거
|
||||
- [x] dynamicCanvasHeight useMemo 추가
|
||||
- [x] MIN_CANVAS_HEIGHT, CANVAS_EXTRA_ROWS 상수 추가
|
||||
- [x] gridLabels 동적 계산 (행 수 자동 조정)
|
||||
- [x] gridCells 동적 계산 (PopRenderer)
|
||||
- [x] 뷰어 프리뷰 모드 스크롤 지원
|
||||
|
||||
### 자동 줄바꿈 시스템 (v5.1)
|
||||
- [x] convertAndResolvePositions() 자동 줄바꿈 로직
|
||||
- [x] 원본 col 보존 로직
|
||||
- [x] 초과 컴포넌트 맨 아래 배치
|
||||
- [x] colSpan 자동 축소
|
||||
|
||||
### 검토 필요 시스템
|
||||
- [x] needsReview() 함수 추가
|
||||
- [x] OutOfBoundsPanel → ReviewPanel 변경
|
||||
- [x] 파란색 테마 (안내 느낌)
|
||||
- [x] 클릭 시 컴포넌트 선택
|
||||
|
||||
### 버그 수정
|
||||
- [x] hiddenComponentIds 중복 정의 에러 수정
|
||||
- [x] useDrop 의존성 배열 수정
|
||||
- [x] 검토 필요 패널 모드별 표시 불일치 수정
|
||||
|
||||
### 그리드 셀 크기 강제 고정 (v5.2.1)
|
||||
- [x] gridAutoRows → gridTemplateRows 변경 (행 높이 강제 고정)
|
||||
- [x] dynamicRowCount를 gridStyle과 gridCells에서 공유
|
||||
- [x] 컴포넌트 overflow: visible → overflow: hidden 변경
|
||||
- [x] PopRenderer dynamicRowCount에서 숨김 컴포넌트 제외
|
||||
- [x] PopCanvas와 PopRenderer의 여유행 기준 통일 (+3)
|
||||
- [x] 디버깅용 console.log 2개 삭제
|
||||
- [x] 뷰어 page.tsx viewportWidth 선언 순서 수정
|
||||
|
||||
---
|
||||
|
||||
## 브레이크포인트 변경 상세
|
||||
|
||||
### 변경 전 → 변경 후
|
||||
|
||||
| 모드 | 변경 전 | 변경 후 | 근거 |
|
||||
|------|--------|--------|------|
|
||||
| mobile_portrait | ~599px | ~479px | 스마트폰 세로 최대 440px |
|
||||
| mobile_landscape | 600~839px | 480~767px | 767px까지 스마트폰 |
|
||||
| tablet_portrait | 840~1023px | 768~1023px | iPad Mini 768px 포함 |
|
||||
| tablet_landscape | 1024px+ | 동일 | 변경 없음 |
|
||||
|
||||
### 연구 결과 (기기별 CSS 뷰포트)
|
||||
|
||||
| 기기 | CSS 뷰포트 너비 |
|
||||
|------|----------------|
|
||||
| iPhone SE | 375px |
|
||||
| iPhone 16 Pro | 402px |
|
||||
| Galaxy S25 Ultra | 440px |
|
||||
| iPad Mini 7 | 768px |
|
||||
| iPad Pro 11 | 834px (세로), 1194px (가로) |
|
||||
| iPad Pro 13 | 1024px (세로), 1366px (가로) |
|
||||
|
||||
---
|
||||
|
||||
## 세로 자동 확장 상세
|
||||
|
||||
### 핵심 상수
|
||||
|
||||
```typescript
|
||||
const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이 (px)
|
||||
const CANVAS_EXTRA_ROWS = 3; // 항상 유지되는 여유 행 수
|
||||
```
|
||||
|
||||
### 동적 높이 계산 로직
|
||||
|
||||
```typescript
|
||||
const dynamicCanvasHeight = useMemo(() => {
|
||||
const visibleComps = Object.values(layout.components)
|
||||
.filter(comp => !hiddenComponentIds.includes(comp.id));
|
||||
|
||||
if (visibleComps.length === 0) return MIN_CANVAS_HEIGHT;
|
||||
|
||||
const maxRowEnd = visibleComps.reduce((max, comp) => {
|
||||
const pos = getEffectivePosition(comp);
|
||||
return Math.max(max, pos.row + pos.rowSpan);
|
||||
}, 1);
|
||||
|
||||
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
|
||||
const height = totalRows * (rowHeight + gap) + padding * 2;
|
||||
|
||||
return Math.max(MIN_CANVAS_HEIGHT, height);
|
||||
}, [dependencies]);
|
||||
```
|
||||
|
||||
### 영향받는 영역
|
||||
|
||||
| 영역 | 변경 |
|
||||
|------|------|
|
||||
| 캔버스 컨테이너 | minHeight: dynamicCanvasHeight |
|
||||
| 디바이스 스크린 | minHeight: dynamicCanvasHeight |
|
||||
| 행 라벨 | 동적 행 수 계산 |
|
||||
| 격자 셀 | 동적 행 수 계산 |
|
||||
|
||||
---
|
||||
|
||||
## 자동 줄바꿈 로직 상세
|
||||
|
||||
### 처리 단계
|
||||
|
||||
```
|
||||
1. 비율 변환 + 원본 col 보존
|
||||
converted = map(comp => ({
|
||||
position: convertPositionToMode(comp.position),
|
||||
originalCol: comp.position.col, // 원본 보존
|
||||
}))
|
||||
|
||||
2. 정상 vs 초과 분리
|
||||
normalComponents = filter(originalCol <= targetColumns)
|
||||
overflowComponents = filter(originalCol > targetColumns)
|
||||
|
||||
3. 초과 컴포넌트 맨 아래 배치
|
||||
maxRow = normalComponents의 최대 (row + rowSpan - 1)
|
||||
overflowComponents → col=1, row=maxRow+1
|
||||
|
||||
4. colSpan 자동 축소
|
||||
if (colSpan > targetColumns) colSpan = targetColumns
|
||||
|
||||
5. 겹침 해결
|
||||
resolveOverlaps([...normalComponents, ...wrappedComponents])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 대화 핵심
|
||||
|
||||
### 반응형 불일치 문제
|
||||
|
||||
**사용자 리포트**:
|
||||
> "아이폰 SE, iPad Pro 프리셋은 잘 되는데,
|
||||
> 브라우저 수동 리사이즈 시 6칸 모드가 적용 안 되는 것 같아"
|
||||
|
||||
**원인 분석**:
|
||||
- useResponsiveMode: width/height 비율로 landscape/portrait 판정
|
||||
- GRID_BREAKPOINTS: 순수 너비 기반
|
||||
- 768~839px 구간에서 불일치 발생
|
||||
|
||||
**해결**:
|
||||
- 뷰어에서 detectGridMode(viewportWidth) 사용
|
||||
- 프리뷰 모드만 useResponsiveModeWithOverride 유지
|
||||
|
||||
### 세로 무한 스크롤 결정
|
||||
|
||||
**사용자 질문**:
|
||||
> "우리 화면 모드는 너비만 신경쓰면 되잖아?
|
||||
> 세로는 무한 스크롤이 가능해야 하겠네?"
|
||||
|
||||
**확인 사항**:
|
||||
1. 너비만 신경쓰면 됨 ✅
|
||||
2. 캔버스 세로 무한 스크롤 필요 ✅
|
||||
3. 뷰어에서 터치 스크롤 지원 ✅
|
||||
|
||||
**구현 방식 선택**:
|
||||
- 수동 행 추가 방식 vs **자동 확장 방식 (채택)**
|
||||
- 이유: 여유 공간 3행 자동 유지, 사용자 부담 최소화
|
||||
|
||||
---
|
||||
|
||||
## 빌드 결과
|
||||
|
||||
```
|
||||
exit_code: 0
|
||||
주요 변경 파일: 6개
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 링크
|
||||
|
||||
- ADR: [decisions/005-breakpoint-redesign.md](../decisions/005-breakpoint-redesign.md)
|
||||
- ADR: [decisions/006-auto-wrap-review-system.md](../decisions/006-auto-wrap-review-system.md)
|
||||
- 이전 세션: [sessions/2026-02-05.md](./2026-02-05.md)
|
||||
|
||||
---
|
||||
|
||||
## 이번 작업에서 배운 것
|
||||
|
||||
### 새로 알게 된 기술 개념
|
||||
|
||||
- **gridAutoRows vs gridTemplateRows**: `gridAutoRows`는 행의 *최소* 높이만 보장하고 콘텐츠에 따라 늘어날 수 있음. `gridTemplateRows`는 행 높이를 *강제 고정*함. 가이드 셀과 컴포넌트가 같은 Grid 컨테이너에 있을 때, 컴포넌트 콘텐츠가 행 높이를 밀어내면 인접한 빈 가이드 셀 크기도 함께 변해 시각적 불일치가 발생함.
|
||||
|
||||
### 발생했던 에러와 원인 패턴
|
||||
|
||||
| 에러 | 원인 패턴 |
|
||||
|------|-----------|
|
||||
| 그리드 셀 크기 불균일 | 같은 CSS Grid에서 gridAutoRows(최소값)를 사용하면 콘텐츠가 행 높이를 변형시킴 |
|
||||
| Canvas vs Renderer 행 수 불일치 | 같은 데이터(행 수)를 두 곳에서 계산하면서 필터 조건(숨김 제외)이 달랐음 |
|
||||
| 디버깅 console.log 잔존 | 기능 완료 후 정리 단계를 생략함 |
|
||||
| viewportWidth 참조 순서 | 변수 사용 코드가 선언 코드보다 위에 위치 (JS 호이스팅으로 동작은 하지만 가독성 저하) |
|
||||
|
||||
### 다음에 비슷한 작업할 때 주의할 점
|
||||
|
||||
1. **CSS Grid에서 "고정 크기" 셀이 필요하면 `gridTemplateRows`를 사용**하고, `gridAutoRows`는 동적 추가행 대비용으로만 유지
|
||||
2. **같은 데이터를 여러 곳에서 계산할 때, 필터 조건이 동일한지 반드시 비교** (숨김 제외 등)
|
||||
3. **기능 완료 후 `console.log`를 Grep으로 검색하여 디버깅 로그 정리**
|
||||
4. **변수 선언 순서는 의존 관계 순서와 일치**시켜야 가독성과 유지보수성 확보
|
||||
|
||||
---
|
||||
|
||||
## 중단점
|
||||
|
||||
> **다음 작업**: Phase 4 실제 컴포넌트 구현
|
||||
> - pop-label, pop-button 등 실제 렌더링 구현
|
||||
> - 데이터 바인딩 연결
|
||||
> - STATUS.md의 "다음 작업" 섹션 참조
|
||||
|
||||
---
|
||||
|
||||
## 다음 작업자 참고
|
||||
|
||||
1. **테스트 필요**
|
||||
- 아이폰 SE 실기기 테스트
|
||||
- iPad Mini 세로 모드 확인
|
||||
- 브라우저 리사이즈로 모드 전환 확인
|
||||
|
||||
2. **향후 작업**
|
||||
- Phase 4: 실제 컴포넌트 구현 (pop-label, pop-button 등)
|
||||
- 데이터 바인딩 연결
|
||||
- 워크플로우 연동
|
||||
Loading…
Reference in New Issue