Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
cc97527183
|
|
@ -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*
|
||||
|
|
@ -293,4 +296,12 @@ uploads/
|
|||
|
||||
claude.md
|
||||
|
||||
.cursor/mcp.json
|
||||
.cursor/mcp.json
|
||||
|
||||
# AI 에이전트 테스트 산출물
|
||||
*-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 시스템 백엔드입니다.
|
||||
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
|
|||
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
|
|
@ -289,6 +290,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
|
|||
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* BOM 이력/버전 관리 컨트롤러
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { logger } from "../utils/logger";
|
||||
import * as bomService from "../services/bomService";
|
||||
|
||||
// ─── 이력 (History) ─────────────────────────────
|
||||
|
||||
export async function getBomHistory(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const tableName = (req.query.tableName as string) || undefined;
|
||||
|
||||
const data = await bomService.getBomHistory(bomId, companyCode, tableName);
|
||||
res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 이력 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function addBomHistory(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const changedBy = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
|
||||
const { change_type, change_description, revision, version, tableName } = req.body;
|
||||
if (!change_type) {
|
||||
res.status(400).json({ success: false, message: "change_type은 필수입니다" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bomService.addBomHistory(bomId, companyCode, {
|
||||
change_type,
|
||||
change_description,
|
||||
revision,
|
||||
version,
|
||||
changed_by: changedBy,
|
||||
}, tableName);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 이력 등록 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BOM 헤더 조회 (entity join 포함) ─────────────────────────
|
||||
|
||||
export async function getBomHeader(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const tableName = (req.query.tableName as string) || undefined;
|
||||
|
||||
const data = await bomService.getBomHeader(bomId, tableName);
|
||||
if (!data) {
|
||||
res.status(404).json({ success: false, message: "BOM을 찾을 수 없습니다" });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 헤더 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 버전 (Version) ─────────────────────────────
|
||||
|
||||
export async function getBomVersions(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const tableName = (req.query.tableName as string) || undefined;
|
||||
|
||||
const result = await bomService.getBomVersions(bomId, companyCode, tableName);
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.versions,
|
||||
currentVersionId: result.currentVersionId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
const { tableName, detailTable } = req.body || {};
|
||||
|
||||
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 생성 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId, versionId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const { tableName, detailTable } = req.body || {};
|
||||
|
||||
const result = await bomService.loadBomVersion(bomId, versionId, companyCode, tableName, detailTable);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 불러오기 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function activateBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId, versionId } = req.params;
|
||||
const { tableName } = req.body || {};
|
||||
|
||||
const result = await bomService.activateBomVersion(bomId, versionId, tableName);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 사용 확정 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId, versionId } = req.params;
|
||||
const tableName = (req.query.tableName as string) || undefined;
|
||||
const detailTable = (req.query.detailTable as string) || undefined;
|
||||
|
||||
const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName, detailTable);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 삭제 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -115,7 +115,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
|||
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { value = "id", label = "name" } = req.query;
|
||||
const { value = "id", label = "name", fields } = req.query;
|
||||
|
||||
// tableName 유효성 검증
|
||||
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||
|
|
@ -167,9 +167,21 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
|
|||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// autoFill용 추가 컬럼 처리
|
||||
let extraColumns = "";
|
||||
if (fields && typeof fields === "string") {
|
||||
const requestedFields = fields.split(",").map((f) => f.trim()).filter(Boolean);
|
||||
const validExtra = requestedFields.filter(
|
||||
(f) => existingColumns.has(f) && f !== valueColumn && f !== effectiveLabelColumn
|
||||
);
|
||||
if (validExtra.length > 0) {
|
||||
extraColumns = ", " + validExtra.map((f) => `"${f}"`).join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
// 쿼리 실행 (최대 500개)
|
||||
const query = `
|
||||
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label
|
||||
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label${extraColumns}
|
||||
FROM ${tableName}
|
||||
${whereClause}
|
||||
ORDER BY ${effectiveLabelColumn} ASC
|
||||
|
|
@ -184,6 +196,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
|
|||
labelColumn: effectiveLabelColumn,
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
extraFields: extraColumns ? true : false,
|
||||
});
|
||||
|
||||
res.json({
|
||||
|
|
|
|||
|
|
@ -39,16 +39,18 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
|
|||
if (search) params.push(`%${search}%`);
|
||||
|
||||
const query = `
|
||||
SELECT DISTINCT
|
||||
SELECT
|
||||
i.id,
|
||||
i.${nameColumn} AS item_name,
|
||||
i.${codeColumn} AS item_code
|
||||
i.${codeColumn} AS item_code,
|
||||
COUNT(rv.id) AS routing_count
|
||||
FROM ${tableName} i
|
||||
INNER JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||
AND rv.company_code = i.company_code
|
||||
WHERE i.company_code = $1
|
||||
${searchCondition}
|
||||
ORDER BY i.${codeColumn}
|
||||
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
|
||||
ORDER BY i.created_date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const result = await getPool().query(query, params);
|
||||
|
|
@ -82,10 +84,10 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R
|
|||
|
||||
// 라우팅 버전 목록
|
||||
const versionsQuery = `
|
||||
SELECT id, version_name, description, created_date
|
||||
SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
|
||||
FROM ${routingVersionTable}
|
||||
WHERE ${routingFkColumn} = $1 AND company_code = $2
|
||||
ORDER BY created_date DESC
|
||||
ORDER BY is_default DESC, created_date DESC
|
||||
`;
|
||||
const versionsResult = await getPool().query(versionsQuery, [
|
||||
itemCode,
|
||||
|
|
@ -127,6 +129,92 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 기본 버전 설정
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 라우팅 버전을 기본 버전으로 설정
|
||||
* 같은 품목의 다른 버전은 기본 해제
|
||||
*/
|
||||
export async function setDefaultVersion(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { versionId } = req.params;
|
||||
const {
|
||||
routingVersionTable = "item_routing_version",
|
||||
routingFkColumn = "item_code",
|
||||
} = req.body;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
const versionResult = await client.query(
|
||||
`SELECT ${routingFkColumn} AS item_code FROM ${routingVersionTable} WHERE id = $1 AND company_code = $2`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
if (versionResult.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
const itemCode = versionResult.rows[0].item_code;
|
||||
|
||||
await client.query(
|
||||
`UPDATE ${routingVersionTable} SET is_default = false WHERE ${routingFkColumn} = $1 AND company_code = $2`,
|
||||
[itemCode, companyCode]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE ${routingVersionTable} SET is_default = true WHERE id = $1 AND company_code = $2`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("기본 버전 설정", { companyCode, versionId, itemCode });
|
||||
return res.json({ success: true, message: "기본 버전이 설정되었습니다" });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("기본 버전 설정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 버전 해제
|
||||
*/
|
||||
export async function unsetDefaultVersion(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { versionId } = req.params;
|
||||
const { routingVersionTable = "item_routing_version" } = req.body;
|
||||
|
||||
await getPool().query(
|
||||
`UPDATE ${routingVersionTable} SET is_default = false WHERE id = $1 AND company_code = $2`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
logger.info("기본 버전 해제", { companyCode, versionId });
|
||||
return res.json({ success: true, message: "기본 버전이 해제되었습니다" });
|
||||
} catch (error: any) {
|
||||
logger.error("기본 버전 해제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 작업 항목 CRUD
|
||||
// ============================================================
|
||||
|
|
@ -330,7 +418,10 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons
|
|||
const { workItemId } = req.params;
|
||||
|
||||
const query = `
|
||||
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, created_date
|
||||
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields,
|
||||
created_date
|
||||
FROM process_work_item_detail
|
||||
WHERE work_item_id = $1 AND company_code = $2
|
||||
ORDER BY sort_order, created_date
|
||||
|
|
@ -355,7 +446,11 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
|||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { work_item_id, detail_type, content, is_required, sort_order, remark } = req.body;
|
||||
const {
|
||||
work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields,
|
||||
} = req.body;
|
||||
|
||||
if (!work_item_id || !content) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -375,8 +470,10 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
|||
|
||||
const query = `
|
||||
INSERT INTO process_work_item_detail
|
||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
@ -389,6 +486,15 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
|||
sort_order || 0,
|
||||
remark || null,
|
||||
writer,
|
||||
inspection_code || null,
|
||||
inspection_method || null,
|
||||
unit || null,
|
||||
lower_limit || null,
|
||||
upper_limit || null,
|
||||
duration_minutes || null,
|
||||
input_type || null,
|
||||
lookup_target || null,
|
||||
display_fields || null,
|
||||
]);
|
||||
|
||||
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
|
||||
|
|
@ -410,7 +516,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
|||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { detail_type, content, is_required, sort_order, remark } = req.body;
|
||||
const {
|
||||
detail_type, content, is_required, sort_order, remark,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields,
|
||||
} = req.body;
|
||||
|
||||
const query = `
|
||||
UPDATE process_work_item_detail
|
||||
|
|
@ -419,6 +529,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
|||
is_required = COALESCE($3, is_required),
|
||||
sort_order = COALESCE($4, sort_order),
|
||||
remark = COALESCE($5, remark),
|
||||
inspection_code = $8,
|
||||
inspection_method = $9,
|
||||
unit = $10,
|
||||
lower_limit = $11,
|
||||
upper_limit = $12,
|
||||
duration_minutes = $13,
|
||||
input_type = $14,
|
||||
lookup_target = $15,
|
||||
display_fields = $16,
|
||||
updated_date = NOW()
|
||||
WHERE id = $6 AND company_code = $7
|
||||
RETURNING *
|
||||
|
|
@ -432,6 +551,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
|||
remark,
|
||||
id,
|
||||
companyCode,
|
||||
inspection_code || null,
|
||||
inspection_method || null,
|
||||
unit || null,
|
||||
lower_limit || null,
|
||||
upper_limit || null,
|
||||
duration_minutes || null,
|
||||
input_type || null,
|
||||
lookup_target || null,
|
||||
display_fields || null,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
|
|
@ -544,8 +672,10 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
|||
for (const detail of item.details) {
|
||||
await client.query(
|
||||
`INSERT INTO process_work_item_detail
|
||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||
[
|
||||
companyCode,
|
||||
workItemId,
|
||||
|
|
@ -555,6 +685,15 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
|||
detail.sort_order || 0,
|
||||
detail.remark || null,
|
||||
writer,
|
||||
detail.inspection_code || null,
|
||||
detail.inspection_method || null,
|
||||
detail.unit || null,
|
||||
detail.lower_limit || null,
|
||||
detail.upper_limit || null,
|
||||
detail.duration_minutes || null,
|
||||
detail.input_type || null,
|
||||
detail.lookup_target || null,
|
||||
detail.display_fields || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -921,14 +921,51 @@ export async function addTableData(
|
|||
}
|
||||
}
|
||||
|
||||
// 회사별 NOT NULL 소프트 제약조건 검증
|
||||
const notNullViolations = await tableManagementService.validateNotNullConstraints(
|
||||
tableName,
|
||||
data,
|
||||
companyCode || "*"
|
||||
);
|
||||
if (notNullViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
|
||||
error: {
|
||||
code: "NOT_NULL_VIOLATION",
|
||||
details: notNullViolations,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사별 UNIQUE 소프트 제약조건 검증
|
||||
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
|
||||
tableName,
|
||||
data,
|
||||
companyCode || "*"
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
error: {
|
||||
code: "UNIQUE_VIOLATION",
|
||||
details: uniqueViolations,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 추가
|
||||
await tableManagementService.addTableData(tableName, data);
|
||||
const result = await tableManagementService.addTableData(tableName, data);
|
||||
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
const response: ApiResponse<{ id: string | null }> = {
|
||||
success: true,
|
||||
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
||||
data: { id: result.insertedId },
|
||||
};
|
||||
|
||||
res.status(201).json(response);
|
||||
|
|
@ -1003,6 +1040,45 @@ export async function editTableData(
|
|||
}
|
||||
|
||||
const tableManagementService = new TableManagementService();
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상)
|
||||
const notNullViolations = await tableManagementService.validateNotNullConstraints(
|
||||
tableName,
|
||||
updatedData,
|
||||
companyCode
|
||||
);
|
||||
if (notNullViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
|
||||
error: {
|
||||
code: "NOT_NULL_VIOLATION",
|
||||
details: notNullViolations,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외)
|
||||
const excludeId = originalData?.id ? String(originalData.id) : undefined;
|
||||
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
|
||||
tableName,
|
||||
updatedData,
|
||||
companyCode,
|
||||
excludeId
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
error: {
|
||||
code: "UNIQUE_VIOLATION",
|
||||
details: uniqueViolations,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 수정
|
||||
await tableManagementService.editTableData(
|
||||
|
|
@ -1693,6 +1769,7 @@ export async function getCategoryColumnsByCompany(
|
|||
let columnsResult;
|
||||
|
||||
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
|
||||
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
|
||||
if (companyCode === "*") {
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
|
|
@ -1712,15 +1789,15 @@ export async function getCategoryColumnsByCompany(
|
|||
ON ttc.table_name = tl.table_name
|
||||
WHERE ttc.input_type = 'category'
|
||||
AND ttc.company_code = '*'
|
||||
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
`;
|
||||
|
||||
columnsResult = await pool.query(columnsQuery);
|
||||
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
|
||||
logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||
rowCount: columnsResult.rows.length
|
||||
});
|
||||
} else {
|
||||
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
ttc.table_name AS "tableName",
|
||||
|
|
@ -1739,11 +1816,12 @@ export async function getCategoryColumnsByCompany(
|
|||
ON ttc.table_name = tl.table_name
|
||||
WHERE ttc.input_type = 'category'
|
||||
AND ttc.company_code = $1
|
||||
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
`;
|
||||
|
||||
columnsResult = await pool.query(columnsQuery, [companyCode]);
|
||||
logger.info("✅ 회사별 카테고리 컬럼 조회 완료", {
|
||||
logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||
companyCode,
|
||||
rowCount: columnsResult.rows.length
|
||||
});
|
||||
|
|
@ -1804,13 +1882,10 @@ export async function getCategoryColumnsByMenu(
|
|||
const { getPool } = await import("../database/db");
|
||||
const pool = getPool();
|
||||
|
||||
// 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회
|
||||
// category_column_mapping 대신 table_type_columns 기준으로 조회
|
||||
logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
|
||||
|
||||
// table_type_columns에서 input_type = 'category' 컬럼 조회
|
||||
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
|
||||
let columnsResult;
|
||||
|
||||
// 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회
|
||||
if (companyCode === "*") {
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
|
|
@ -1830,15 +1905,15 @@ export async function getCategoryColumnsByMenu(
|
|||
ON ttc.table_name = tl.table_name
|
||||
WHERE ttc.input_type = 'category'
|
||||
AND ttc.company_code = '*'
|
||||
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
`;
|
||||
|
||||
columnsResult = await pool.query(columnsQuery);
|
||||
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
|
||||
logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||
rowCount: columnsResult.rows.length
|
||||
});
|
||||
} else {
|
||||
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
ttc.table_name AS "tableName",
|
||||
|
|
@ -1857,11 +1932,12 @@ export async function getCategoryColumnsByMenu(
|
|||
ON ttc.table_name = tl.table_name
|
||||
WHERE ttc.input_type = 'category'
|
||||
AND ttc.company_code = $1
|
||||
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
`;
|
||||
|
||||
columnsResult = await pool.query(columnsQuery, [companyCode]);
|
||||
logger.info("✅ 회사별 카테고리 컬럼 조회 완료", {
|
||||
logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||
companyCode,
|
||||
rowCount: columnsResult.rows.length
|
||||
});
|
||||
|
|
@ -2616,8 +2692,22 @@ export async function toggleTableIndex(
|
|||
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
|
||||
|
||||
if (action === "create") {
|
||||
let indexColumns = `"${columnName}"`;
|
||||
|
||||
// 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장)
|
||||
if (indexType === "unique") {
|
||||
const hasCompanyCode = await query(
|
||||
`SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`,
|
||||
[tableName]
|
||||
);
|
||||
if (hasCompanyCode.length > 0) {
|
||||
indexColumns = `"company_code", "${columnName}"`;
|
||||
logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
|
||||
const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`;
|
||||
const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`;
|
||||
logger.info(`인덱스 생성: ${sql}`);
|
||||
await query(sql);
|
||||
} else if (action === "drop") {
|
||||
|
|
@ -2638,22 +2728,55 @@ export async function toggleTableIndex(
|
|||
} catch (error: any) {
|
||||
logger.error("인덱스 토글 오류:", error);
|
||||
|
||||
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내
|
||||
const errorMsg = error.message?.includes("duplicate key")
|
||||
? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요."
|
||||
: "인덱스 설정 중 오류가 발생했습니다.";
|
||||
const errMsg = error.message || "";
|
||||
let userMessage = "인덱스 설정 중 오류가 발생했습니다.";
|
||||
let duplicates: any[] = [];
|
||||
|
||||
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패
|
||||
if (
|
||||
errMsg.includes("could not create unique index") ||
|
||||
errMsg.includes("duplicate key")
|
||||
) {
|
||||
const { columnName, tableName } = { ...req.params, ...req.body };
|
||||
try {
|
||||
duplicates = await query(
|
||||
`SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
duplicates = await query(
|
||||
`SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
|
||||
);
|
||||
} catch { /* 중복 조회 실패 시 무시 */ }
|
||||
}
|
||||
|
||||
const dupDetails = duplicates.length > 0
|
||||
? duplicates.map((d: any) => {
|
||||
const company = d.company_code ? `[${d.company_code}] ` : "";
|
||||
return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`;
|
||||
}).join(", ")
|
||||
: "";
|
||||
|
||||
userMessage = dupDetails
|
||||
? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}`
|
||||
: `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMsg,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
message: userMessage,
|
||||
error: errMsg,
|
||||
duplicates,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT NULL 토글
|
||||
* NOT NULL 토글 (회사별 소프트 제약조건)
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
|
||||
*
|
||||
* DB 레벨 ALTER TABLE 대신 table_type_columns.is_nullable을 회사별로 관리한다.
|
||||
* 멀티테넌시 환경에서 회사 A는 NOT NULL, 회사 B는 NULL 허용이 가능하다.
|
||||
*/
|
||||
export async function toggleColumnNullable(
|
||||
req: AuthenticatedRequest,
|
||||
|
|
@ -2662,6 +2785,7 @@ export async function toggleColumnNullable(
|
|||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { nullable } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!tableName || !columnName || typeof nullable !== "boolean") {
|
||||
res.status(400).json({
|
||||
|
|
@ -2671,18 +2795,54 @@ export async function toggleColumnNullable(
|
|||
return;
|
||||
}
|
||||
|
||||
if (nullable) {
|
||||
// NOT NULL 해제
|
||||
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`;
|
||||
logger.info(`NOT NULL 해제: ${sql}`);
|
||||
await query(sql);
|
||||
} else {
|
||||
// NOT NULL 설정
|
||||
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`;
|
||||
logger.info(`NOT NULL 설정: ${sql}`);
|
||||
await query(sql);
|
||||
// is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL
|
||||
const isNullableValue = nullable ? "Y" : "N";
|
||||
|
||||
if (!nullable) {
|
||||
// NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인
|
||||
const hasCompanyCode = await query<{ column_name: string }>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
if (hasCompanyCode.length > 0) {
|
||||
const nullCheckQuery = companyCode === "*"
|
||||
? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL`
|
||||
: `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`;
|
||||
const nullCheckParams = companyCode === "*" ? [] : [companyCode];
|
||||
|
||||
const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams);
|
||||
const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10);
|
||||
|
||||
if (nullCount > 0) {
|
||||
logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, {
|
||||
companyCode,
|
||||
nullCount,
|
||||
});
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// table_type_columns에 회사별 is_nullable 설정 UPSERT
|
||||
await query(
|
||||
`INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET is_nullable = $3, updated_date = NOW()`,
|
||||
[tableName, columnName, isNullableValue, companyCode]
|
||||
);
|
||||
|
||||
logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, {
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: nullable
|
||||
|
|
@ -2692,14 +2852,95 @@ export async function toggleColumnNullable(
|
|||
} catch (error: any) {
|
||||
logger.error("NOT NULL 토글 오류:", error);
|
||||
|
||||
// NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내
|
||||
const errorMsg = error.message?.includes("contains null values")
|
||||
? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요."
|
||||
: "NOT NULL 설정 중 오류가 발생했습니다.";
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMsg,
|
||||
message: "NOT NULL 설정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UNIQUE 토글 (회사별 소프트 제약조건)
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
|
||||
*
|
||||
* DB 레벨 인덱스 대신 table_type_columns.is_unique를 회사별로 관리한다.
|
||||
* 저장 시 앱 레벨에서 중복 검증을 수행한다.
|
||||
*/
|
||||
export async function toggleColumnUnique(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { unique } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!tableName || !columnName || typeof unique !== "boolean") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, columnName, unique(boolean)이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isUniqueValue = unique ? "Y" : "N";
|
||||
|
||||
if (unique) {
|
||||
// UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인
|
||||
const hasCompanyCode = await query<{ column_name: string }>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
if (hasCompanyCode.length > 0) {
|
||||
const dupQuery = companyCode === "*"
|
||||
? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`
|
||||
: `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`;
|
||||
const dupParams = companyCode === "*" ? [] : [companyCode];
|
||||
|
||||
const dupResult = await query<any>(dupQuery, dupParams);
|
||||
|
||||
if (dupResult.length > 0) {
|
||||
const dupDetails = dupResult
|
||||
.map((d: any) => `"${d[columnName]}" (${d.cnt}건)`)
|
||||
.join(", ");
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// table_type_columns에 회사별 is_unique 설정 UPSERT
|
||||
await query(
|
||||
`INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET is_unique = $3, updated_date = NOW()`,
|
||||
[tableName, columnName, isUniqueValue, companyCode]
|
||||
);
|
||||
|
||||
logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, {
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: unique
|
||||
? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.`
|
||||
: `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("UNIQUE 토글 오류:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "UNIQUE 설정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* BOM 이력/버전 관리 라우트
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as bomController from "../controllers/bomController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// BOM 헤더 (entity join 포함)
|
||||
router.get("/:bomId/header", bomController.getBomHeader);
|
||||
|
||||
// 이력
|
||||
router.get("/:bomId/history", bomController.getBomHistory);
|
||||
router.post("/:bomId/history", bomController.addBomHistory);
|
||||
|
||||
// 버전
|
||||
router.get("/:bomId/versions", bomController.getBomVersions);
|
||||
router.post("/:bomId/versions", bomController.createBomVersion);
|
||||
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
|
||||
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
|
||||
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
|
||||
|
||||
export default router;
|
||||
|
|
@ -14,6 +14,10 @@ router.use(authenticateToken);
|
|||
router.get("/items", ctrl.getItemsWithRouting);
|
||||
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
|
||||
|
||||
// 기본 버전 설정/해제
|
||||
router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion);
|
||||
router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion);
|
||||
|
||||
// 작업 항목 CRUD
|
||||
router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems);
|
||||
router.post("/work-items", ctrl.createWorkItem);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
setTablePrimaryKey, // 🆕 PK 설정
|
||||
toggleTableIndex, // 🆕 인덱스 토글
|
||||
toggleColumnNullable, // 🆕 NOT NULL 토글
|
||||
toggleColumnUnique, // 🆕 UNIQUE 토글
|
||||
} from "../controllers/tableManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -161,6 +162,12 @@ router.post("/tables/:tableName/indexes", toggleTableIndex);
|
|||
*/
|
||||
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
|
||||
|
||||
/**
|
||||
* UNIQUE 토글
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
|
||||
*/
|
||||
router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique);
|
||||
|
||||
/**
|
||||
* 테이블 존재 여부 확인
|
||||
* GET /api/table-management/tables/:tableName/exists
|
||||
|
|
|
|||
|
|
@ -0,0 +1,292 @@
|
|||
/**
|
||||
* BOM 이력 및 버전 관리 서비스
|
||||
* 행(Row) 기반 버전 관리: bom_detail.version_id로 버전별 데이터 분리
|
||||
*/
|
||||
|
||||
import { query, queryOne, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
function safeTableName(name: string, fallback: string): string {
|
||||
if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return fallback;
|
||||
return name;
|
||||
}
|
||||
|
||||
// ─── 이력 (History) ─────────────────────────────
|
||||
|
||||
export async function getBomHistory(bomId: string, companyCode: string, tableName?: string) {
|
||||
const table = safeTableName(tableName || "", "bom_history");
|
||||
const sql = companyCode === "*"
|
||||
? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY changed_date DESC`
|
||||
: `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY changed_date DESC`;
|
||||
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
|
||||
return query(sql, params);
|
||||
}
|
||||
|
||||
export async function addBomHistory(
|
||||
bomId: string,
|
||||
companyCode: string,
|
||||
data: {
|
||||
revision?: string;
|
||||
version?: string;
|
||||
change_type: string;
|
||||
change_description?: string;
|
||||
changed_by?: string;
|
||||
},
|
||||
tableName?: string,
|
||||
) {
|
||||
const table = safeTableName(tableName || "", "bom_history");
|
||||
const sql = `
|
||||
INSERT INTO ${table} (bom_id, revision, version, change_type, change_description, changed_by, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
`;
|
||||
return queryOne(sql, [
|
||||
bomId,
|
||||
data.revision || null,
|
||||
data.version || null,
|
||||
data.change_type,
|
||||
data.change_description || null,
|
||||
data.changed_by || null,
|
||||
companyCode,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── 버전 (Version) ─────────────────────────────
|
||||
|
||||
// ─── BOM 헤더 조회 (entity join 포함) ─────────────────────────────
|
||||
|
||||
export async function getBomHeader(bomId: string, tableName?: string) {
|
||||
const table = safeTableName(tableName || "", "bom");
|
||||
const sql = `
|
||||
SELECT b.*,
|
||||
i.item_name, i.item_number, i.division as item_type, i.unit
|
||||
FROM ${table} b
|
||||
LEFT JOIN item_info i ON b.item_id = i.id
|
||||
WHERE b.id = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
return queryOne<Record<string, any>>(sql, [bomId]);
|
||||
}
|
||||
|
||||
export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) {
|
||||
const table = safeTableName(tableName || "", "bom_version");
|
||||
const dTable = "bom_detail";
|
||||
|
||||
// 버전 목록 + 각 버전별 디테일 건수 + 현재 활성 버전 ID
|
||||
const sql = companyCode === "*"
|
||||
? `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count
|
||||
FROM ${table} v WHERE v.bom_id = $1 ORDER BY v.created_date DESC`
|
||||
: `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count
|
||||
FROM ${table} v WHERE v.bom_id = $1 AND v.company_code = $2 ORDER BY v.created_date DESC`;
|
||||
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
|
||||
const versions = await query(sql, params);
|
||||
|
||||
// bom.current_version_id도 함께 반환
|
||||
const bomRow = await queryOne<{ current_version_id: string }>(
|
||||
`SELECT current_version_id FROM bom WHERE id = $1`, [bomId],
|
||||
);
|
||||
|
||||
return {
|
||||
versions,
|
||||
currentVersionId: bomRow?.current_version_id || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 버전 생성: 현재 활성 버전의 bom_detail 행을 복사하여 새 version_id로 INSERT
|
||||
*/
|
||||
export async function createBomVersion(
|
||||
bomId: string, companyCode: string, createdBy: string,
|
||||
versionTableName?: string, detailTableName?: string,
|
||||
) {
|
||||
const vTable = safeTableName(versionTableName || "", "bom_version");
|
||||
const dTable = safeTableName(detailTableName || "", "bom_detail");
|
||||
|
||||
return transaction(async (client) => {
|
||||
const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]);
|
||||
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
|
||||
const bomData = bomRow.rows[0];
|
||||
|
||||
// 다음 버전 번호 결정
|
||||
const lastVersion = await client.query(
|
||||
`SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`,
|
||||
[bomId],
|
||||
);
|
||||
let nextVersionNum = 1;
|
||||
if (lastVersion.rows.length > 0) {
|
||||
const parsed = parseFloat(lastVersion.rows[0].version_name);
|
||||
if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1;
|
||||
}
|
||||
const versionName = `${nextVersionNum}.0`;
|
||||
|
||||
// 새 버전 레코드 생성 (snapshot_data 없이)
|
||||
const insertSql = `
|
||||
INSERT INTO ${vTable} (bom_id, version_name, revision, status, created_by, company_code)
|
||||
VALUES ($1, $2, $3, 'developing', $4, $5)
|
||||
RETURNING *
|
||||
`;
|
||||
const newVersion = await client.query(insertSql, [
|
||||
bomId,
|
||||
versionName,
|
||||
bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0,
|
||||
createdBy,
|
||||
companyCode,
|
||||
]);
|
||||
const newVersionId = newVersion.rows[0].id;
|
||||
|
||||
// 현재 활성 버전의 bom_detail 행을 복사
|
||||
const sourceVersionId = bomData.current_version_id;
|
||||
if (sourceVersionId) {
|
||||
const sourceDetails = await client.query(
|
||||
`SELECT * FROM ${dTable} WHERE bom_id = $1 AND version_id = $2 ORDER BY parent_detail_id NULLS FIRST, id`,
|
||||
[bomId, sourceVersionId],
|
||||
);
|
||||
|
||||
// old ID → new ID 매핑 (parent_detail_id 유지)
|
||||
const oldToNew: Record<string, string> = {};
|
||||
for (const d of sourceDetails.rows) {
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO ${dTable} (bom_id, version_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, seq_no, writer, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id`,
|
||||
[
|
||||
bomId,
|
||||
newVersionId,
|
||||
d.parent_detail_id ? (oldToNew[d.parent_detail_id] || null) : null,
|
||||
d.child_item_id,
|
||||
d.quantity,
|
||||
d.unit,
|
||||
d.process_type,
|
||||
d.loss_rate,
|
||||
d.remark,
|
||||
d.level,
|
||||
d.base_qty,
|
||||
d.revision,
|
||||
d.seq_no,
|
||||
d.writer,
|
||||
companyCode,
|
||||
],
|
||||
);
|
||||
oldToNew[d.id] = insertResult.rows[0].id;
|
||||
}
|
||||
|
||||
logger.info("BOM 버전 생성 - 디테일 복사 완료", {
|
||||
bomId, versionName, sourceVersionId, copiedCount: sourceDetails.rows.length,
|
||||
});
|
||||
}
|
||||
|
||||
// BOM 헤더의 version과 current_version_id 갱신
|
||||
await client.query(
|
||||
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
|
||||
[versionName, newVersionId, bomId],
|
||||
);
|
||||
|
||||
logger.info("BOM 버전 생성 완료", { bomId, versionName, newVersionId, companyCode });
|
||||
return newVersion.rows[0];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 불러오기: bom_detail 삭제/복원 없이 current_version_id만 전환
|
||||
*/
|
||||
export async function loadBomVersion(
|
||||
bomId: string, versionId: string, companyCode: string,
|
||||
versionTableName?: string, _detailTableName?: string,
|
||||
) {
|
||||
const vTable = safeTableName(versionTableName || "", "bom_version");
|
||||
|
||||
return transaction(async (client) => {
|
||||
const verRow = await client.query(
|
||||
`SELECT * FROM ${vTable} WHERE id = $1 AND bom_id = $2`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
|
||||
|
||||
const versionName = verRow.rows[0].version_name;
|
||||
|
||||
// BOM 헤더의 version과 current_version_id만 전환
|
||||
await client.query(
|
||||
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
|
||||
[versionName, versionId, bomId],
|
||||
);
|
||||
|
||||
logger.info("BOM 버전 불러오기 완료", { bomId, versionId, versionName });
|
||||
return { restored: true, versionName };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 확정: 선택 버전을 active로 변경 + current_version_id 갱신
|
||||
*/
|
||||
export async function activateBomVersion(bomId: string, versionId: string, tableName?: string) {
|
||||
const table = safeTableName(tableName || "", "bom_version");
|
||||
|
||||
return transaction(async (client) => {
|
||||
const verRow = await client.query(
|
||||
`SELECT version_name FROM ${table} WHERE id = $1 AND bom_id = $2`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
|
||||
|
||||
// 기존 active -> inactive
|
||||
await client.query(
|
||||
`UPDATE ${table} SET status = 'inactive' WHERE bom_id = $1 AND status = 'active'`,
|
||||
[bomId],
|
||||
);
|
||||
// 선택한 버전 -> active
|
||||
await client.query(
|
||||
`UPDATE ${table} SET status = 'active' WHERE id = $1`,
|
||||
[versionId],
|
||||
);
|
||||
// BOM 헤더 갱신
|
||||
const versionName = verRow.rows[0].version_name;
|
||||
await client.query(
|
||||
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
|
||||
[versionName, versionId, bomId],
|
||||
);
|
||||
|
||||
logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName });
|
||||
return { activated: true, versionName };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제
|
||||
*/
|
||||
export async function deleteBomVersion(
|
||||
bomId: string, versionId: string,
|
||||
tableName?: string, detailTableName?: string,
|
||||
) {
|
||||
const table = safeTableName(tableName || "", "bom_version");
|
||||
const dTable = safeTableName(detailTableName || "", "bom_detail");
|
||||
|
||||
return transaction(async (client) => {
|
||||
// active 상태 버전은 삭제 불가
|
||||
const checkResult = await client.query(
|
||||
`SELECT status FROM ${table} WHERE id = $1 AND bom_id = $2`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
if (checkResult.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
|
||||
if (checkResult.rows[0].status === "active") {
|
||||
throw new Error("사용중인 버전은 삭제할 수 없습니다");
|
||||
}
|
||||
|
||||
// 해당 버전의 bom_detail 행 삭제
|
||||
const deleteDetails = await client.query(
|
||||
`DELETE FROM ${dTable} WHERE bom_id = $1 AND version_id = $2`,
|
||||
[bomId, versionId],
|
||||
);
|
||||
|
||||
// 버전 레코드 삭제
|
||||
const deleteVersion = await client.query(
|
||||
`DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
|
||||
logger.info("BOM 버전 삭제", {
|
||||
bomId, versionId,
|
||||
deletedDetails: deleteDetails.rowCount,
|
||||
});
|
||||
|
||||
return deleteVersion.rows.length > 0;
|
||||
});
|
||||
}
|
||||
|
|
@ -14,6 +14,35 @@ interface NumberingRulePart {
|
|||
autoConfig?: any;
|
||||
manualConfig?: any;
|
||||
generatedValue?: string;
|
||||
separatorAfter?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트 배열에서 autoConfig.separatorAfter를 파트 레벨로 추출
|
||||
*/
|
||||
function extractSeparatorAfterFromParts(parts: any[]): any[] {
|
||||
return parts.map((part) => {
|
||||
if (part.autoConfig?.separatorAfter !== undefined) {
|
||||
part.separatorAfter = part.autoConfig.separatorAfter;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트별 개별 구분자를 사용하여 코드 결합
|
||||
* 마지막 파트의 separatorAfter는 무시됨
|
||||
*/
|
||||
function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string {
|
||||
let result = "";
|
||||
partValues.forEach((val, idx) => {
|
||||
result += val;
|
||||
if (idx < partValues.length - 1) {
|
||||
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
result += sep;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
interface NumberingRuleConfig {
|
||||
|
|
@ -141,7 +170,7 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
const partsResult = await pool.query(partsQuery, partsParams);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
}
|
||||
|
||||
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, {
|
||||
|
|
@ -274,7 +303,7 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
const partsResult = await pool.query(partsQuery, partsParams);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
}
|
||||
|
||||
return result.rows;
|
||||
|
|
@ -381,7 +410,7 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
const partsResult = await pool.query(partsQuery, partsParams);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
logger.info("✅ 규칙 파트 조회 성공", {
|
||||
ruleId: rule.ruleId,
|
||||
|
|
@ -517,7 +546,7 @@ class NumberingRuleService {
|
|||
companyCode === "*" ? rule.companyCode : companyCode,
|
||||
]);
|
||||
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
}
|
||||
|
||||
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, {
|
||||
|
|
@ -633,7 +662,7 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
const partsResult = await pool.query(partsQuery, partsParams);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
|
@ -708,17 +737,25 @@ class NumberingRuleService {
|
|||
manual_config AS "manualConfig"
|
||||
`;
|
||||
|
||||
// auto_config에 separatorAfter 포함
|
||||
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
|
||||
|
||||
const partResult = await client.query(insertPartQuery, [
|
||||
config.ruleId,
|
||||
part.order,
|
||||
part.partType,
|
||||
part.generationMethod,
|
||||
JSON.stringify(part.autoConfig || {}),
|
||||
JSON.stringify(autoConfigWithSep),
|
||||
JSON.stringify(part.manualConfig || {}),
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
parts.push(partResult.rows[0]);
|
||||
const savedPart = partResult.rows[0];
|
||||
// autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동
|
||||
if (savedPart.autoConfig?.separatorAfter !== undefined) {
|
||||
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
|
||||
}
|
||||
parts.push(savedPart);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
|
@ -820,17 +857,23 @@ class NumberingRuleService {
|
|||
manual_config AS "manualConfig"
|
||||
`;
|
||||
|
||||
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
|
||||
|
||||
const partResult = await client.query(insertPartQuery, [
|
||||
ruleId,
|
||||
part.order,
|
||||
part.partType,
|
||||
part.generationMethod,
|
||||
JSON.stringify(part.autoConfig || {}),
|
||||
JSON.stringify(autoConfigWithSep),
|
||||
JSON.stringify(part.manualConfig || {}),
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
parts.push(partResult.rows[0]);
|
||||
const savedPart = partResult.rows[0];
|
||||
if (savedPart.autoConfig?.separatorAfter !== undefined) {
|
||||
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
|
||||
}
|
||||
parts.push(savedPart);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1053,7 +1096,8 @@ class NumberingRuleService {
|
|||
}
|
||||
}));
|
||||
|
||||
const previewCode = parts.join(rule.separator || "");
|
||||
const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||
const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || "");
|
||||
logger.info("코드 미리보기 생성", {
|
||||
ruleId,
|
||||
previewCode,
|
||||
|
|
@ -1164,8 +1208,8 @@ class NumberingRuleService {
|
|||
}
|
||||
}));
|
||||
|
||||
const separator = rule.separator || "";
|
||||
const previewTemplate = previewParts.join(separator);
|
||||
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
|
||||
|
||||
// 사용자 입력 코드에서 수동 입력 부분 추출
|
||||
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
|
||||
|
|
@ -1382,7 +1426,8 @@ class NumberingRuleService {
|
|||
}
|
||||
}));
|
||||
|
||||
const allocatedCode = parts.join(rule.separator || "");
|
||||
const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||
const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || "");
|
||||
|
||||
// 순번이 있는 경우에만 증가
|
||||
const hasSequence = rule.parts.some(
|
||||
|
|
@ -1541,7 +1586,7 @@ class NumberingRuleService {
|
|||
rule.ruleId,
|
||||
companyCode === "*" ? rule.companyCode : companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
}
|
||||
|
||||
logger.info("[테스트] 채번 규칙 목록 조회 완료", {
|
||||
|
|
@ -1634,7 +1679,7 @@ class NumberingRuleService {
|
|||
rule.ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
|
||||
ruleId: rule.ruleId,
|
||||
|
|
@ -1754,12 +1799,14 @@ class NumberingRuleService {
|
|||
auto_config, manual_config, company_code, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
`;
|
||||
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
|
||||
|
||||
await client.query(partInsertQuery, [
|
||||
config.ruleId,
|
||||
part.order,
|
||||
part.partType,
|
||||
part.generationMethod,
|
||||
JSON.stringify(part.autoConfig || {}),
|
||||
JSON.stringify(autoConfigWithSep),
|
||||
JSON.stringify(part.manualConfig || {}),
|
||||
companyCode,
|
||||
]);
|
||||
|
|
@ -1914,7 +1961,7 @@ class NumberingRuleService {
|
|||
rule.ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
|
||||
ruleId: rule.ruleId,
|
||||
|
|
@ -1973,7 +2020,7 @@ class NumberingRuleService {
|
|||
rule.ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
|
||||
ruleId: rule.ruleId,
|
||||
|
|
@ -2056,7 +2103,7 @@ class NumberingRuleService {
|
|||
rule.ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
}
|
||||
|
||||
return result.rows;
|
||||
|
|
|
|||
|
|
@ -199,7 +199,15 @@ export class TableManagementService {
|
|||
cl.input_type as "cl_input_type",
|
||||
COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings",
|
||||
COALESCE(ttc.description, cl.description, '') as "description",
|
||||
c.is_nullable as "isNullable",
|
||||
CASE
|
||||
WHEN COALESCE(ttc.is_nullable, cl.is_nullable) IS NOT NULL
|
||||
THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END
|
||||
ELSE c.is_nullable
|
||||
END as "isNullable",
|
||||
CASE
|
||||
WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES'
|
||||
ELSE 'NO'
|
||||
END as "isUnique",
|
||||
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
||||
c.column_default as "defaultValue",
|
||||
c.character_maximum_length as "maxLength",
|
||||
|
|
@ -241,7 +249,15 @@ export class TableManagementService {
|
|||
COALESCE(cl.input_type, 'direct') as "inputType",
|
||||
COALESCE(cl.detail_settings::text, '') as "detailSettings",
|
||||
COALESCE(cl.description, '') as "description",
|
||||
c.is_nullable as "isNullable",
|
||||
CASE
|
||||
WHEN cl.is_nullable IS NOT NULL
|
||||
THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END
|
||||
ELSE c.is_nullable
|
||||
END as "isNullable",
|
||||
CASE
|
||||
WHEN cl.is_unique = 'Y' THEN 'YES'
|
||||
ELSE 'NO'
|
||||
END as "isUnique",
|
||||
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
||||
c.column_default as "defaultValue",
|
||||
c.character_maximum_length as "maxLength",
|
||||
|
|
@ -502,8 +518,8 @@ export class TableManagementService {
|
|||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
code_category, code_value, reference_table, reference_column,
|
||||
display_column, display_order, is_visible, is_nullable,
|
||||
company_code, created_date, updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW())
|
||||
company_code, category_ref, created_date, updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, $14, NOW(), NOW())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET
|
||||
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
|
||||
|
|
@ -516,6 +532,7 @@ export class TableManagementService {
|
|||
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
|
||||
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
|
||||
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
|
||||
category_ref = EXCLUDED.category_ref,
|
||||
updated_date = NOW()`,
|
||||
[
|
||||
tableName,
|
||||
|
|
@ -531,6 +548,7 @@ export class TableManagementService {
|
|||
settings.displayOrder || 0,
|
||||
settings.isVisible !== undefined ? settings.isVisible : true,
|
||||
companyCode,
|
||||
settings.categoryRef || null,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -1599,7 +1617,8 @@ export class TableManagementService {
|
|||
tableName,
|
||||
columnName,
|
||||
actualValue,
|
||||
paramIndex
|
||||
paramIndex,
|
||||
operator
|
||||
);
|
||||
|
||||
case "entity":
|
||||
|
|
@ -1612,7 +1631,14 @@ export class TableManagementService {
|
|||
);
|
||||
|
||||
default:
|
||||
// 기본 문자열 검색 (actualValue 사용)
|
||||
// operator에 따라 정확 일치 또는 부분 일치 검색
|
||||
if (operator === "equals") {
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
values: [String(actualValue)],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${actualValue}%`],
|
||||
|
|
@ -1626,10 +1652,19 @@ export class TableManagementService {
|
|||
);
|
||||
// 오류 시 기본 검색으로 폴백
|
||||
let fallbackValue = value;
|
||||
let fallbackOperator = "contains";
|
||||
if (typeof value === "object" && value !== null && "value" in value) {
|
||||
fallbackValue = value.value;
|
||||
fallbackOperator = value.operator || "contains";
|
||||
}
|
||||
|
||||
if (fallbackOperator === "equals") {
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
values: [String(fallbackValue)],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${fallbackValue}%`],
|
||||
|
|
@ -1776,7 +1811,8 @@ export class TableManagementService {
|
|||
tableName: string,
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
paramIndex: number,
|
||||
operator: string = "contains"
|
||||
): Promise<{
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
|
|
@ -1786,7 +1822,14 @@ export class TableManagementService {
|
|||
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
|
||||
|
||||
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
|
||||
// 코드 타입이 아니면 기본 검색
|
||||
// 코드 타입이 아니면 operator에 따라 검색
|
||||
if (operator === "equals") {
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
values: [String(value)],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
|
|
@ -1794,6 +1837,15 @@ export class TableManagementService {
|
|||
};
|
||||
}
|
||||
|
||||
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
|
||||
if (operator === "equals") {
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
values: [String(value)],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
// 코드값 또는 코드명으로 검색
|
||||
return {
|
||||
|
|
@ -2431,6 +2483,154 @@ export class TableManagementService {
|
|||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사별 NOT NULL 소프트 제약조건 검증
|
||||
* table_type_columns.is_nullable = 'N'인 컬럼에 NULL/빈값이 들어오면 위반 목록을 반환한다.
|
||||
*/
|
||||
async validateNotNullConstraints(
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
companyCode: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
|
||||
const notNullColumns = await query<{ column_name: string; column_label: string }>(
|
||||
`SELECT
|
||||
ttc.column_name,
|
||||
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||
FROM table_type_columns ttc
|
||||
WHERE ttc.table_name = $1
|
||||
AND ttc.is_nullable = 'N'
|
||||
AND ttc.company_code = $2`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
// 회사별 설정이 없으면 공통 설정 확인
|
||||
if (notNullColumns.length === 0 && companyCode !== "*") {
|
||||
const globalNotNull = await query<{ column_name: string; column_label: string }>(
|
||||
`SELECT
|
||||
ttc.column_name,
|
||||
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||
FROM table_type_columns ttc
|
||||
WHERE ttc.table_name = $1
|
||||
AND ttc.is_nullable = 'N'
|
||||
AND ttc.company_code = '*'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM table_type_columns ttc2
|
||||
WHERE ttc2.table_name = ttc.table_name
|
||||
AND ttc2.column_name = ttc.column_name
|
||||
AND ttc2.company_code = $2
|
||||
)`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
notNullColumns.push(...globalNotNull);
|
||||
}
|
||||
|
||||
if (notNullColumns.length === 0) return [];
|
||||
|
||||
const violations: string[] = [];
|
||||
for (const col of notNullColumns) {
|
||||
const value = data[col.column_name];
|
||||
// NULL, undefined, 빈 문자열을 NOT NULL 위반으로 처리
|
||||
if (value === null || value === undefined || value === "") {
|
||||
violations.push(col.column_label);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
} catch (error) {
|
||||
logger.error(`NOT NULL 검증 오류: ${tableName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사별 UNIQUE 소프트 제약조건 검증
|
||||
* table_type_columns.is_unique = 'Y'인 컬럼에 중복 값이 들어오면 위반 목록을 반환한다.
|
||||
* @param excludeId 수정 시 자기 자신은 제외
|
||||
*/
|
||||
async validateUniqueConstraints(
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
companyCode: string,
|
||||
excludeId?: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
|
||||
let uniqueColumns = await query<{ column_name: string; column_label: string }>(
|
||||
`SELECT
|
||||
ttc.column_name,
|
||||
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||
FROM table_type_columns ttc
|
||||
WHERE ttc.table_name = $1
|
||||
AND ttc.is_unique = 'Y'
|
||||
AND ttc.company_code = $2`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
// 회사별 설정이 없으면 공통 설정 확인
|
||||
if (uniqueColumns.length === 0 && companyCode !== "*") {
|
||||
const globalUnique = await query<{ column_name: string; column_label: string }>(
|
||||
`SELECT
|
||||
ttc.column_name,
|
||||
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||
FROM table_type_columns ttc
|
||||
WHERE ttc.table_name = $1
|
||||
AND ttc.is_unique = 'Y'
|
||||
AND ttc.company_code = '*'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM table_type_columns ttc2
|
||||
WHERE ttc2.table_name = ttc.table_name
|
||||
AND ttc2.column_name = ttc.column_name
|
||||
AND ttc2.company_code = $2
|
||||
)`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
uniqueColumns = globalUnique;
|
||||
}
|
||||
|
||||
if (uniqueColumns.length === 0) return [];
|
||||
|
||||
const violations: string[] = [];
|
||||
for (const col of uniqueColumns) {
|
||||
const value = data[col.column_name];
|
||||
if (value === null || value === undefined || value === "") continue;
|
||||
|
||||
// 해당 회사 내에서 같은 값이 이미 존재하는지 확인
|
||||
const hasCompanyCode = await query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
let dupQuery: string;
|
||||
let dupParams: any[];
|
||||
|
||||
if (hasCompanyCode.length > 0 && companyCode !== "*") {
|
||||
dupQuery = excludeId
|
||||
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1`
|
||||
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`;
|
||||
dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode];
|
||||
} else {
|
||||
dupQuery = excludeId
|
||||
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1`
|
||||
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`;
|
||||
dupParams = excludeId ? [value, excludeId] : [value];
|
||||
}
|
||||
|
||||
const dupResult = await query(dupQuery, dupParams);
|
||||
if (dupResult.length > 0) {
|
||||
violations.push(`${col.column_label} (${value})`);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
} catch (error) {
|
||||
logger.error(`UNIQUE 검증 오류: ${tableName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블에 데이터 추가
|
||||
* @returns 무시된 컬럼 정보 (디버깅용)
|
||||
|
|
@ -2438,7 +2638,7 @@ export class TableManagementService {
|
|||
async addTableData(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
|
||||
): Promise<{ skippedColumns: string[]; savedColumns: string[]; insertedId: string | null }> {
|
||||
try {
|
||||
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
|
||||
logger.info(`추가할 데이터:`, data);
|
||||
|
|
@ -2551,19 +2751,21 @@ export class TableManagementService {
|
|||
const insertQuery = `
|
||||
INSERT INTO "${tableName}" (${columnNames})
|
||||
VALUES (${placeholders})
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
logger.info(`실행할 쿼리: ${insertQuery}`);
|
||||
logger.info(`쿼리 파라미터:`, values);
|
||||
|
||||
await query(insertQuery, values);
|
||||
const insertResult = await query(insertQuery, values) as any[];
|
||||
const insertedId = insertResult?.[0]?.id ?? null;
|
||||
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`);
|
||||
|
||||
// 무시된 컬럼과 저장된 컬럼 정보 반환
|
||||
return {
|
||||
skippedColumns,
|
||||
savedColumns: existingColumns,
|
||||
insertedId,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
|
||||
|
|
@ -4353,7 +4555,8 @@ export class TableManagementService {
|
|||
END as "detailSettings",
|
||||
ttc.is_nullable as "isNullable",
|
||||
ic.data_type as "dataType",
|
||||
ttc.company_code as "companyCode"
|
||||
ttc.company_code as "companyCode",
|
||||
ttc.category_ref as "categoryRef"
|
||||
FROM table_type_columns ttc
|
||||
LEFT JOIN information_schema.columns ic
|
||||
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
||||
|
|
@ -4430,20 +4633,24 @@ export class TableManagementService {
|
|||
}
|
||||
|
||||
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
|
||||
const baseInfo = {
|
||||
const baseInfo: any = {
|
||||
tableName: tableName,
|
||||
columnName: col.columnName,
|
||||
displayName: col.displayName,
|
||||
dataType: col.dataType || "varchar",
|
||||
inputType: col.inputType,
|
||||
detailSettings: col.detailSettings,
|
||||
description: "", // 필수 필드 추가
|
||||
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
|
||||
description: "",
|
||||
isNullable: col.isNullable === "Y" ? "Y" : "N",
|
||||
isPrimaryKey: false,
|
||||
displayOrder: 0,
|
||||
isVisible: true,
|
||||
};
|
||||
|
||||
if (col.categoryRef) {
|
||||
baseInfo.categoryRef = col.categoryRef;
|
||||
}
|
||||
|
||||
// 카테고리 타입인 경우 categoryMenus 추가
|
||||
if (
|
||||
col.inputType === "category" &&
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
# BOM 관리 시스템 개발 현황
|
||||
|
||||
## 1. 개요
|
||||
|
||||
BOM(Bill of Materials) 관리 시스템은 제품의 구성 부품을 계층적으로 관리하는 기능입니다.
|
||||
V2 컴포넌트 기반으로 구현되어 있으며, 설정 패널을 통해 모든 기능을 동적으로 구성할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 아키텍처
|
||||
|
||||
### 2.1 전체 구조
|
||||
|
||||
```
|
||||
[프론트엔드] [백엔드] [데이터베이스]
|
||||
v2-bom-tree (트리 뷰) ──── /api/bom ────── bomService.ts ────── bom, bom_detail
|
||||
v2-bom-item-editor ──── /api/table-management ──────────── bom_history, bom_version
|
||||
V2BomTreeConfigPanel (설정 패널)
|
||||
```
|
||||
|
||||
### 2.2 관련 파일 목록
|
||||
|
||||
#### 프론트엔드
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` | BOM 트리/레벨 뷰 메인 컴포넌트 |
|
||||
| `frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx` | 버전 관리 모달 |
|
||||
| `frontend/lib/registry/components/v2-bom-tree/BomHistoryModal.tsx` | 이력 관리 모달 |
|
||||
| `frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx` | BOM 항목 수정 모달 |
|
||||
| `frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx` | 트리 렌더러 |
|
||||
| `frontend/lib/registry/components/v2-bom-tree/index.ts` | 컴포넌트 정의 (v2-bom-tree) |
|
||||
| `frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx` | BOM 트리 설정 패널 |
|
||||
| `frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx` | BOM 항목 편집기 (에디터 모드) |
|
||||
|
||||
#### 백엔드
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `backend-node/src/routes/bomRoutes.ts` | BOM API 라우트 정의 |
|
||||
| `backend-node/src/controllers/bomController.ts` | BOM 컨트롤러 (이력/버전) |
|
||||
| `backend-node/src/services/bomService.ts` | BOM 서비스 (비즈니스 로직) |
|
||||
|
||||
#### 데이터베이스
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `db/migrations/062_create_bom_history_version_tables.sql` | 이력/버전 테이블 DDL |
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터베이스 스키마
|
||||
|
||||
### 3.1 bom (BOM 헤더)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR (UUID) | PK |
|
||||
| item_id | VARCHAR | 완제품 품목 ID (item_info FK) |
|
||||
| bom_name | VARCHAR | BOM 명칭 |
|
||||
| version | VARCHAR | 현재 사용중인 버전명 |
|
||||
| revision | VARCHAR | 차수 |
|
||||
| base_qty | NUMERIC | 기준수량 |
|
||||
| unit | VARCHAR | 단위 |
|
||||
| remark | TEXT | 비고 |
|
||||
| company_code | VARCHAR | 회사 코드 (멀티테넌시) |
|
||||
|
||||
### 3.2 bom_detail (BOM 상세 - 자식 품목)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR (UUID) | PK |
|
||||
| bom_id | VARCHAR | BOM 헤더 FK |
|
||||
| parent_detail_id | VARCHAR | 부모 detail FK (NULL = 1레벨) |
|
||||
| child_item_id | VARCHAR | 자식 품목 ID (item_info FK) |
|
||||
| quantity | NUMERIC | 구성수량 (소요량) |
|
||||
| unit | VARCHAR | 단위 |
|
||||
| process_type | VARCHAR | 공정구분 (제조/외주 등) |
|
||||
| loss_rate | NUMERIC | 손실율 |
|
||||
| level | INTEGER | 레벨 |
|
||||
| base_qty | NUMERIC | 기준수량 |
|
||||
| revision | VARCHAR | 차수 |
|
||||
| remark | TEXT | 비고 |
|
||||
| company_code | VARCHAR | 회사 코드 |
|
||||
|
||||
### 3.3 bom_history (BOM 이력)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR (UUID) | PK |
|
||||
| bom_id | VARCHAR | BOM 헤더 FK |
|
||||
| revision | VARCHAR | 차수 |
|
||||
| version | VARCHAR | 버전 |
|
||||
| change_type | VARCHAR | 변경구분 (등록/수정/추가/삭제) |
|
||||
| change_description | TEXT | 변경내용 |
|
||||
| changed_by | VARCHAR | 변경자 |
|
||||
| changed_date | TIMESTAMP | 변경일시 |
|
||||
| company_code | VARCHAR | 회사 코드 |
|
||||
|
||||
### 3.4 bom_version (BOM 버전)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR (UUID) | PK |
|
||||
| bom_id | VARCHAR | BOM 헤더 FK |
|
||||
| version_name | VARCHAR | 버전명 (1.0, 2.0 ...) |
|
||||
| revision | INTEGER | 생성 시점의 차수 |
|
||||
| status | VARCHAR | 상태 (developing / active / inactive) |
|
||||
| snapshot_data | JSONB | 스냅샷 (bom 헤더 + bom_detail 전체) |
|
||||
| created_by | VARCHAR | 생성자 |
|
||||
| created_date | TIMESTAMP | 생성일시 |
|
||||
| company_code | VARCHAR | 회사 코드 |
|
||||
|
||||
---
|
||||
|
||||
## 4. API 명세
|
||||
|
||||
### 4.1 이력 API
|
||||
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/bom/:bomId/history` | 이력 목록 조회 |
|
||||
| POST | `/api/bom/:bomId/history` | 이력 등록 |
|
||||
|
||||
**Query Params**: `tableName` (설정 패널에서 지정한 이력 테이블명, 기본값: `bom_history`)
|
||||
|
||||
### 4.2 버전 API
|
||||
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/bom/:bomId/versions` | 버전 목록 조회 |
|
||||
| POST | `/api/bom/:bomId/versions` | 신규 버전 생성 |
|
||||
| POST | `/api/bom/:bomId/versions/:versionId/load` | 버전 불러오기 (데이터 복원) |
|
||||
| POST | `/api/bom/:bomId/versions/:versionId/activate` | 버전 사용 확정 |
|
||||
| DELETE | `/api/bom/:bomId/versions/:versionId` | 버전 삭제 |
|
||||
|
||||
**Body/Query**: `tableName`, `detailTable` (설정 패널에서 지정한 테이블명)
|
||||
|
||||
---
|
||||
|
||||
## 5. 버전 관리 구조
|
||||
|
||||
### 5.1 핵심 원리
|
||||
|
||||
**각 버전은 생성 시점의 BOM 전체 구조(헤더 + 모든 디테일)를 JSONB 스냅샷으로 저장합니다.**
|
||||
|
||||
```
|
||||
버전 1.0 (active)
|
||||
└─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] }
|
||||
|
||||
버전 2.0 (developing)
|
||||
└─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] }
|
||||
|
||||
버전 3.0 (inactive)
|
||||
└─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] }
|
||||
```
|
||||
|
||||
### 5.2 버전 상태 (status)
|
||||
|
||||
| 상태 | 설명 |
|
||||
|------|------|
|
||||
| `developing` | 개발중 - 신규 생성 시 기본 상태 |
|
||||
| `active` | 사용중 - "사용 확정" 후 운영 상태 |
|
||||
| `inactive` | 사용중지 - 이전에 active였다가 다른 버전이 확정된 경우 |
|
||||
|
||||
### 5.3 버전 워크플로우
|
||||
|
||||
```
|
||||
[현재 BOM 데이터]
|
||||
│
|
||||
▼
|
||||
신규 버전 생성 ───► 버전 N.0 (status: developing)
|
||||
│
|
||||
├── 불러오기: 해당 스냅샷의 데이터로 현재 BOM을 복원
|
||||
│ (status 변경 없음, BOM 헤더 version 변경 없음)
|
||||
│
|
||||
├── 사용 확정: status → active,
|
||||
│ 기존 active 버전 → inactive,
|
||||
│ BOM 헤더의 version 필드 갱신
|
||||
│
|
||||
└── 삭제: active 상태가 아닌 경우만 삭제 가능
|
||||
```
|
||||
|
||||
### 5.4 불러오기 vs 사용 확정
|
||||
|
||||
| 동작 | 불러오기 (Load) | 사용 확정 (Activate) |
|
||||
|------|----------------|---------------------|
|
||||
| BOM 데이터 복원 | O (detail 전체 교체) | X |
|
||||
| BOM 헤더 업데이트 | O (base_qty, unit 등) | version 필드만 |
|
||||
| 버전 status 변경 | X | active로 변경 |
|
||||
| 기존 active 비활성화 | X | O (→ inactive) |
|
||||
| BOM 목록 새로고침 | O (refreshTable) | O (refreshTable) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 설정 패널 구성
|
||||
|
||||
`V2BomTreeConfigPanel.tsx`에서 아래 항목을 설정할 수 있습니다:
|
||||
|
||||
### 6.1 기본 탭
|
||||
|
||||
| 설정 항목 | 설명 | 기본값 |
|
||||
|-----------|------|--------|
|
||||
| 디테일 테이블 | BOM 상세 데이터 테이블 | `bom_detail` |
|
||||
| 외래키 | BOM 헤더와의 연결 키 | `bom_id` |
|
||||
| 부모키 | 부모-자식 관계 키 | `parent_detail_id` |
|
||||
| 이력 테이블 | BOM 변경 이력 테이블 | `bom_history` |
|
||||
| 버전 테이블 | BOM 버전 관리 테이블 | `bom_version` |
|
||||
| 이력 기능 표시 | 이력 버튼 노출 여부 | `true` |
|
||||
| 버전 기능 표시 | 버전 버튼 노출 여부 | `true` |
|
||||
|
||||
### 6.2 컬럼 탭
|
||||
|
||||
- 소스 테이블 (bom/item_info 등)에서 표시할 컬럼 선택
|
||||
- 디테일 테이블에서 표시할 컬럼 선택
|
||||
- 컬럼 순서 드래그앤드롭
|
||||
- 컬럼별 라벨, 너비, 정렬 설정
|
||||
|
||||
---
|
||||
|
||||
## 7. 뷰 모드
|
||||
|
||||
### 7.1 트리 뷰 (기본)
|
||||
|
||||
- 계층적 들여쓰기로 부모-자식 관계 표현
|
||||
- 레벨별 시각 구분:
|
||||
- **0레벨 (가상 루트)**: 파란색 배경 + 파란 좌측 바
|
||||
- **1레벨**: 흰색 배경 + 초록 좌측 바
|
||||
- **2레벨**: 연회색 배경 + 주황 좌측 바
|
||||
- **3레벨 이상**: 진회색 배경 + 보라 좌측 바
|
||||
- 펼침/접힘 (정전개/역전개)
|
||||
|
||||
### 7.2 레벨 뷰
|
||||
|
||||
- 평면 테이블 형태로 표시
|
||||
- "레벨0", "레벨1", "레벨2" ... 컬럼에 체크마크로 계층 표시
|
||||
- 같은 레벨별 배경색 구분 적용
|
||||
|
||||
---
|
||||
|
||||
## 8. 주요 기능 목록
|
||||
|
||||
| 기능 | 상태 | 설명 |
|
||||
|------|------|------|
|
||||
| BOM 트리 표시 | 완료 | 계층적 트리 뷰 + 레벨 뷰 |
|
||||
| BOM 항목 편집 | 완료 | 더블클릭으로 수정 모달 (0레벨: bom, 하위: bom_detail) |
|
||||
| 이력 관리 | 완료 | 변경 이력 조회/등록 모달 |
|
||||
| 버전 관리 | 완료 | 버전 생성/불러오기/사용 확정/삭제 |
|
||||
| 설정 패널 | 완료 | 테이블/컬럼/기능 동적 설정 |
|
||||
| 디자인 모드 프리뷰 | 완료 | 실제 화면과 일치하는 디자인 모드 표시 |
|
||||
| 컬럼 크기 조절 | 완료 | 헤더 드래그로 컬럼 너비 변경 |
|
||||
| 텍스트 말줄임 | 완료 | 긴 텍스트 `...` 처리 |
|
||||
| 레벨별 시각 구분 | 완료 | 배경색 + 좌측 컬러 바 |
|
||||
| 정전개/역전개 | 완료 | 전체 펼침/접기 토글 |
|
||||
| 좌우 스크롤 | 완료 | 컬럼 크기가 커질 때 수평 스크롤 |
|
||||
| BOM 목록 자동 새로고침 | 완료 | 버전 불러오기/확정 후 좌측 패널 자동 리프레시 |
|
||||
| BOM 하위 품목 저장 | 완료 | BomItemEditorComponent에서 직접 INSERT/UPDATE/DELETE |
|
||||
| 차수 (Revision) 자동 증가 | 미구현 | BOM 변경 시 헤더 revision 자동 +1 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 보안 고려사항
|
||||
|
||||
- **SQL 인젝션 방지**: `safeTableName()` 함수로 테이블명 검증 (`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||
- **멀티테넌시**: 모든 API에서 `company_code` 필터링 적용
|
||||
- **최고 관리자**: `company_code = "*"` 시 전체 데이터 조회 가능
|
||||
- **인증**: `authenticateToken` 미들웨어로 모든 라우트 보호
|
||||
|
||||
---
|
||||
|
||||
## 10. 향후 개선 사항
|
||||
|
||||
- [ ] 차수(Revision) 자동 증가 구현 (BOM 헤더 레벨)
|
||||
- [ ] 버전 비교 기능 (두 버전 간 diff)
|
||||
- [ ] BOM 복사 기능
|
||||
- [ ] 이력 자동 등록 (수정/저장 시 자동으로 이력 생성)
|
||||
- [ ] Excel 내보내기/가져오기
|
||||
- [ ] BOM 유효성 검증 (순환참조 방지 등)
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
## ⚠️ 문서 사용 안내
|
||||
|
||||
> **이 문서는 "품목정보" 화면의 구현 예시입니다.**
|
||||
|
||||
>
|
||||
> ### 📌 중요: JSON 데이터는 참고용입니다!
|
||||
>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ interface ColumnTypeInfo {
|
|||
detailSettings: string;
|
||||
description: string;
|
||||
isNullable: string;
|
||||
isUnique: string;
|
||||
defaultValue?: string;
|
||||
maxLength?: number;
|
||||
numericPrecision?: number;
|
||||
|
|
@ -72,9 +73,10 @@ interface ColumnTypeInfo {
|
|||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열
|
||||
hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할
|
||||
numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID
|
||||
categoryMenus?: number[];
|
||||
hierarchyRole?: "large" | "medium" | "small";
|
||||
numberingRuleId?: string;
|
||||
categoryRef?: string | null;
|
||||
}
|
||||
|
||||
interface SecondLevelMenu {
|
||||
|
|
@ -382,10 +384,12 @@ export default function TableManagementPage() {
|
|||
|
||||
return {
|
||||
...col,
|
||||
inputType: col.inputType || "text", // 기본값: text
|
||||
numberingRuleId, // 🆕 채번규칙 ID
|
||||
categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보
|
||||
hierarchyRole, // 계층구조 역할
|
||||
inputType: col.inputType || "text",
|
||||
isUnique: col.isUnique || "NO",
|
||||
numberingRuleId,
|
||||
categoryMenus: col.categoryMenus || [],
|
||||
hierarchyRole,
|
||||
categoryRef: col.categoryRef || null,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -668,15 +672,16 @@ export default function TableManagementPage() {
|
|||
}
|
||||
|
||||
const columnSetting = {
|
||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
||||
columnName: column.columnName,
|
||||
columnLabel: column.displayName,
|
||||
inputType: column.inputType || "text",
|
||||
detailSettings: finalDetailSettings,
|
||||
codeCategory: column.codeCategory || "",
|
||||
codeValue: column.codeValue || "",
|
||||
referenceTable: column.referenceTable || "",
|
||||
referenceColumn: column.referenceColumn || "",
|
||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
displayColumn: column.displayColumn || "",
|
||||
categoryRef: column.categoryRef || null,
|
||||
};
|
||||
|
||||
// console.log("저장할 컬럼 설정:", columnSetting);
|
||||
|
|
@ -703,9 +708,9 @@ export default function TableManagementPage() {
|
|||
length: column.categoryMenus?.length || 0,
|
||||
});
|
||||
|
||||
if (column.inputType === "category") {
|
||||
// 1. 먼저 기존 매핑 모두 삭제
|
||||
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", {
|
||||
if (column.inputType === "category" && !column.categoryRef) {
|
||||
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리
|
||||
console.log("기존 카테고리 메뉴 매핑 삭제 시작:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.columnName,
|
||||
});
|
||||
|
|
@ -864,8 +869,8 @@ export default function TableManagementPage() {
|
|||
}
|
||||
|
||||
return {
|
||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
||||
columnName: column.columnName,
|
||||
columnLabel: column.displayName,
|
||||
inputType: column.inputType || "text",
|
||||
detailSettings: finalDetailSettings,
|
||||
description: column.description || "",
|
||||
|
|
@ -873,7 +878,8 @@ export default function TableManagementPage() {
|
|||
codeValue: column.codeValue || "",
|
||||
referenceTable: column.referenceTable || "",
|
||||
referenceColumn: column.referenceColumn || "",
|
||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
displayColumn: column.displayColumn || "",
|
||||
categoryRef: column.categoryRef || null,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -886,8 +892,8 @@ export default function TableManagementPage() {
|
|||
);
|
||||
|
||||
if (response.data.success) {
|
||||
// 🆕 Category 타입 컬럼들의 메뉴 매핑 처리
|
||||
const categoryColumns = columns.filter((col) => col.inputType === "category");
|
||||
// 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외)
|
||||
const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef);
|
||||
|
||||
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
|
||||
totalColumns: columns.length,
|
||||
|
|
@ -1091,9 +1097,9 @@ export default function TableManagementPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// 인덱스 토글 핸들러
|
||||
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
|
||||
const handleIndexToggle = useCallback(
|
||||
async (columnName: string, indexType: "index" | "unique", checked: boolean) => {
|
||||
async (columnName: string, indexType: "index", checked: boolean) => {
|
||||
if (!selectedTable) return;
|
||||
const action = checked ? "create" : "drop";
|
||||
try {
|
||||
|
|
@ -1122,14 +1128,41 @@ export default function TableManagementPage() {
|
|||
const hasIndex = constraints.indexes.some(
|
||||
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
|
||||
);
|
||||
const hasUnique = constraints.indexes.some(
|
||||
(idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
|
||||
);
|
||||
return { isPk, hasIndex, hasUnique };
|
||||
return { isPk, hasIndex };
|
||||
},
|
||||
[constraints],
|
||||
);
|
||||
|
||||
// UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴)
|
||||
const handleUniqueToggle = useCallback(
|
||||
async (columnName: string, currentIsUnique: string) => {
|
||||
if (!selectedTable) return;
|
||||
const isCurrentlyUnique = currentIsUnique === "YES";
|
||||
const newUnique = !isCurrentlyUnique;
|
||||
try {
|
||||
const response = await apiClient.put(
|
||||
`/table-management/tables/${selectedTable}/columns/${columnName}/unique`,
|
||||
{ unique: newUnique },
|
||||
);
|
||||
if (response.data.success) {
|
||||
toast.success(response.data.message);
|
||||
setColumns((prev) =>
|
||||
prev.map((col) =>
|
||||
col.columnName === columnName
|
||||
? { ...col, isUnique: newUnique ? "YES" : "NO" }
|
||||
: col,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
toast.error(response.data.message || "UNIQUE 설정 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다.");
|
||||
}
|
||||
},
|
||||
[selectedTable],
|
||||
);
|
||||
|
||||
// NOT NULL 토글 핸들러
|
||||
const handleNullableToggle = useCallback(
|
||||
async (columnName: string, currentIsNullable: string) => {
|
||||
|
|
@ -1662,7 +1695,30 @@ export default function TableManagementPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */}
|
||||
{/* 카테고리 타입: 참조 설정 */}
|
||||
{column.inputType === "category" && (
|
||||
<div className="w-56">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">카테고리 참조 (선택)</label>
|
||||
<Input
|
||||
value={column.categoryRef || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value || null;
|
||||
setColumns((prev) =>
|
||||
prev.map((c) =>
|
||||
c.columnName === column.columnName
|
||||
? { ...c, categoryRef: val }
|
||||
: c
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder="테이블명.컬럼명"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
다른 테이블의 카테고리 값 참조 시 입력
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||
{column.inputType === "entity" && (
|
||||
<>
|
||||
|
|
@ -2029,12 +2085,12 @@ export default function TableManagementPage() {
|
|||
aria-label={`${column.columnName} 인덱스 설정`}
|
||||
/>
|
||||
</div>
|
||||
{/* UQ 체크박스 */}
|
||||
{/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={idxState.hasUnique}
|
||||
onCheckedChange={(checked) =>
|
||||
handleIndexToggle(column.columnName, "unique", checked as boolean)
|
||||
checked={column.isUnique === "YES"}
|
||||
onCheckedChange={() =>
|
||||
handleUniqueToggle(column.columnName, column.isUnique)
|
||||
}
|
||||
aria-label={`${column.columnName} 유니크 설정`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -87,10 +87,12 @@ function ScreenViewPage() {
|
|||
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
|
||||
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
|
||||
|
||||
// 🆕 레이어 시스템 지원
|
||||
// 레이어 시스템 지원
|
||||
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
|
||||
// 🆕 조건부 영역(Zone) 목록
|
||||
// 조건부 영역(Zone) 목록
|
||||
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
|
||||
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
|
||||
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
|
||||
|
||||
// 편집 모달 상태
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
|
|
@ -378,11 +380,51 @@ function ScreenViewPage() {
|
|||
}
|
||||
});
|
||||
|
||||
return newActiveIds;
|
||||
}, [formData, conditionalLayers, layout]);
|
||||
// 강제 활성화된 레이어 ID 병합
|
||||
for (const forcedId of forceActivatedLayerIds) {
|
||||
if (!newActiveIds.includes(forcedId)) {
|
||||
newActiveIds.push(forcedId);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
||||
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
|
||||
return newActiveIds;
|
||||
}, [formData, conditionalLayers, layout, forceActivatedLayerIds]);
|
||||
|
||||
// 데이터 전달에 의한 레이어 강제 활성화 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleActivateLayer = (e: Event) => {
|
||||
const { componentId, targetLayerId } = (e as CustomEvent).detail || {};
|
||||
if (!componentId && !targetLayerId) return;
|
||||
|
||||
// targetLayerId가 직접 지정된 경우
|
||||
if (targetLayerId) {
|
||||
setForceActivatedLayerIds((prev) =>
|
||||
prev.includes(targetLayerId) ? prev : [...prev, targetLayerId],
|
||||
);
|
||||
console.log(`🔓 [레이어 강제 활성화] layerId: ${targetLayerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// componentId로 해당 컴포넌트가 속한 레이어를 찾아 활성화
|
||||
for (const layer of conditionalLayers) {
|
||||
const found = layer.components.some((comp) => comp.id === componentId);
|
||||
if (found) {
|
||||
setForceActivatedLayerIds((prev) =>
|
||||
prev.includes(layer.id) ? prev : [...prev, layer.id],
|
||||
);
|
||||
console.log(`🔓 [레이어 강제 활성화] componentId: ${componentId} → layerId: ${layer.id}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("activateLayerForComponent", handleActivateLayer);
|
||||
return () => {
|
||||
window.removeEventListener("activateLayerForComponent", handleActivateLayer);
|
||||
};
|
||||
}, [conditionalLayers]);
|
||||
|
||||
// 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
||||
useEffect(() => {
|
||||
const loadMainTableData = async () => {
|
||||
if (!screen || !layout || !layout.components || !companyCode) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useEffect, ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { AuthLogger } from "@/lib/authLogger";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface AuthGuardProps {
|
||||
|
|
@ -41,11 +42,13 @@ export function AuthGuard({
|
|||
}
|
||||
|
||||
if (requireAuth && !isLoggedIn) {
|
||||
AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`);
|
||||
router.push(redirectTo);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireAdmin && !isAdmin) {
|
||||
AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`);
|
||||
router.push(redirectTo);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -942,17 +942,22 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
continue;
|
||||
}
|
||||
|
||||
// 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용)
|
||||
// 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
|
||||
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`);
|
||||
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
|
||||
if (numberingResponse.data?.success && generatedCode) {
|
||||
dataToSave[numberingInfo.columnName] = generatedCode;
|
||||
const existingValue = dataToSave[numberingInfo.columnName];
|
||||
const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
|
||||
|
||||
if (!hasExcelValue) {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`);
|
||||
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
|
||||
if (numberingResponse.data?.success && generatedCode) {
|
||||
dataToSave[numberingInfo.columnName] = generatedCode;
|
||||
}
|
||||
} catch (numError) {
|
||||
console.error("채번 오류:", numError);
|
||||
}
|
||||
} catch (numError) {
|
||||
console.error("채번 오류:", numError);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
|||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
|
||||
interface ScreenModalState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -1025,6 +1026,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</div>
|
||||
) : screenData ? (
|
||||
<ScreenContextProvider
|
||||
screenId={modalState.screenId || undefined}
|
||||
tableName={screenData.screenInfo?.tableName}
|
||||
>
|
||||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
|
|
@ -1254,6 +1259,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</TableOptionsProvider>
|
||||
</ActiveTabProvider>
|
||||
</ScreenContextProvider>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
isPreview = false,
|
||||
}) => {
|
||||
return (
|
||||
<Card className="border-border bg-card">
|
||||
<Card className="border-border bg-card flex-1">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline" className="text-xs sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -62,9 +62,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
||||
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
||||
|
||||
// 구분자 관련 상태
|
||||
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
|
||||
const [customSeparator, setCustomSeparator] = useState("");
|
||||
// 구분자 관련 상태 (개별 파트 사이 구분자)
|
||||
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
||||
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
||||
|
||||
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
|
||||
interface CategoryOption {
|
||||
|
|
@ -192,48 +192,68 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
}
|
||||
}, [currentRule, onChange]);
|
||||
|
||||
// currentRule이 변경될 때 구분자 상태 동기화
|
||||
// currentRule이 변경될 때 파트별 구분자 상태 동기화
|
||||
useEffect(() => {
|
||||
if (currentRule) {
|
||||
const sep = currentRule.separator ?? "-";
|
||||
// 빈 문자열이면 "none"
|
||||
if (sep === "") {
|
||||
setSeparatorType("none");
|
||||
setCustomSeparator("");
|
||||
return;
|
||||
}
|
||||
// 미리 정의된 구분자인지 확인 (none, custom 제외)
|
||||
const predefinedOption = SEPARATOR_OPTIONS.find(
|
||||
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
|
||||
);
|
||||
if (predefinedOption) {
|
||||
setSeparatorType(predefinedOption.value);
|
||||
setCustomSeparator("");
|
||||
} else {
|
||||
// 직접 입력된 구분자
|
||||
setSeparatorType("custom");
|
||||
setCustomSeparator(sep);
|
||||
}
|
||||
if (currentRule && currentRule.parts.length > 0) {
|
||||
const newSepTypes: Record<number, SeparatorType> = {};
|
||||
const newCustomSeps: Record<number, string> = {};
|
||||
|
||||
currentRule.parts.forEach((part) => {
|
||||
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
|
||||
if (sep === "") {
|
||||
newSepTypes[part.order] = "none";
|
||||
newCustomSeps[part.order] = "";
|
||||
} else {
|
||||
const predefinedOption = SEPARATOR_OPTIONS.find(
|
||||
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
|
||||
);
|
||||
if (predefinedOption) {
|
||||
newSepTypes[part.order] = predefinedOption.value;
|
||||
newCustomSeps[part.order] = "";
|
||||
} else {
|
||||
newSepTypes[part.order] = "custom";
|
||||
newCustomSeps[part.order] = sep;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setSeparatorTypes(newSepTypes);
|
||||
setCustomSeparators(newCustomSeps);
|
||||
}
|
||||
}, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
|
||||
}, [currentRule?.ruleId]);
|
||||
|
||||
// 구분자 변경 핸들러
|
||||
const handleSeparatorChange = useCallback((type: SeparatorType) => {
|
||||
setSeparatorType(type);
|
||||
// 개별 파트 구분자 변경 핸들러
|
||||
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
|
||||
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type }));
|
||||
if (type !== "custom") {
|
||||
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
|
||||
const newSeparator = option?.displayValue ?? "";
|
||||
setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null);
|
||||
setCustomSeparator("");
|
||||
setCustomSeparators(prev => ({ ...prev, [partOrder]: "" }));
|
||||
setCurrentRule((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
parts: prev.parts.map((part) =>
|
||||
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 직접 입력 구분자 변경 핸들러
|
||||
const handleCustomSeparatorChange = useCallback((value: string) => {
|
||||
// 최대 2자 제한
|
||||
// 개별 파트 직접 입력 구분자 변경 핸들러
|
||||
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
|
||||
const trimmedValue = value.slice(0, 2);
|
||||
setCustomSeparator(trimmedValue);
|
||||
setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null);
|
||||
setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue }));
|
||||
setCurrentRule((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
parts: prev.parts.map((part) =>
|
||||
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
|
||||
),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAddPart = useCallback(() => {
|
||||
|
|
@ -250,6 +270,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
partType: "text",
|
||||
generationMethod: "auto",
|
||||
autoConfig: { textValue: "CODE" },
|
||||
separatorAfter: "-",
|
||||
};
|
||||
|
||||
setCurrentRule((prev) => {
|
||||
|
|
@ -257,6 +278,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
return { ...prev, parts: [...prev.parts, newPart] };
|
||||
});
|
||||
|
||||
// 새 파트의 구분자 상태 초기화
|
||||
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
|
||||
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
|
||||
|
||||
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
||||
}, [currentRule, maxRules]);
|
||||
|
||||
|
|
@ -573,42 +598,6 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 두 번째 줄: 구분자 설정 */}
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="w-48 space-y-2">
|
||||
<Label className="text-sm font-medium">구분자</Label>
|
||||
<Select
|
||||
value={separatorType}
|
||||
onValueChange={(value) => handleSeparatorChange(value as SeparatorType)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="구분자 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEPARATOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{separatorType === "custom" && (
|
||||
<div className="w-32 space-y-2">
|
||||
<Label className="text-sm font-medium">직접 입력</Label>
|
||||
<Input
|
||||
value={customSeparator}
|
||||
onChange={(e) => handleCustomSeparatorChange(e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="최대 2자"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground pb-2 text-xs">
|
||||
규칙 사이에 들어갈 문자입니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -625,15 +614,48 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
<p className="text-muted-foreground text-xs sm:text-sm">규칙을 추가하여 코드를 구성하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<div className="flex flex-wrap items-stretch gap-3">
|
||||
{currentRule.parts.map((part, index) => (
|
||||
<NumberingRuleCard
|
||||
key={`part-${part.order}-${index}`}
|
||||
part={part}
|
||||
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
||||
onDelete={() => handleDeletePart(part.order)}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
<React.Fragment key={`part-${part.order}-${index}`}>
|
||||
<div className="flex w-[200px] flex-col">
|
||||
<NumberingRuleCard
|
||||
part={part}
|
||||
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
||||
onDelete={() => handleDeletePart(part.order)}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
|
||||
{index < currentRule.parts.length - 1 && (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-[10px] whitespace-nowrap">뒤 구분자</span>
|
||||
<Select
|
||||
value={separatorTypes[part.order] || "-"}
|
||||
onValueChange={(value) => handlePartSeparatorChange(part.order, value as SeparatorType)}
|
||||
>
|
||||
<SelectTrigger className="h-6 flex-1 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEPARATOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{separatorTypes[part.order] === "custom" && (
|
||||
<Input
|
||||
value={customSeparators[part.order] || ""}
|
||||
onChange={(e) => handlePartCustomSeparatorChange(part.order, e.target.value)}
|
||||
className="h-6 w-14 text-center text-[10px]"
|
||||
placeholder="2자"
|
||||
maxLength={2}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -17,75 +17,71 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
|||
return "규칙을 추가해주세요";
|
||||
}
|
||||
|
||||
const parts = config.parts
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((part) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return part.manualConfig?.value || "XXX";
|
||||
const sortedParts = config.parts.sort((a, b) => a.order - b.order);
|
||||
|
||||
const partValues = sortedParts.map((part) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return part.manualConfig?.value || "XXX";
|
||||
}
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
return String(startFrom).padStart(length, "0");
|
||||
}
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
||||
switch (part.partType) {
|
||||
// 1. 순번 (자동 증가)
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
return String(startFrom).padStart(length, "0");
|
||||
}
|
||||
|
||||
// 2. 숫자 (고정 자릿수)
|
||||
case "number": {
|
||||
const length = autoConfig.numberLength || 4;
|
||||
const value = autoConfig.numberValue || 0;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
||||
// 3. 날짜
|
||||
case "date": {
|
||||
const format = autoConfig.dateFormat || "YYYYMMDD";
|
||||
|
||||
// 컬럼 기준 생성인 경우 placeholder 표시
|
||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
|
||||
// 형식에 맞는 placeholder 반환
|
||||
switch (format) {
|
||||
case "YYYY": return "[YYYY]";
|
||||
case "YY": return "[YY]";
|
||||
case "YYYYMM": return "[YYYYMM]";
|
||||
case "YYMM": return "[YYMM]";
|
||||
case "YYYYMMDD": return "[YYYYMMDD]";
|
||||
case "YYMMDD": return "[YYMMDD]";
|
||||
default: return "[DATE]";
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 날짜 기준 생성
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
|
||||
case "number": {
|
||||
const length = autoConfig.numberLength || 4;
|
||||
const value = autoConfig.numberValue || 0;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
case "date": {
|
||||
const format = autoConfig.dateFormat || "YYYYMMDD";
|
||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
|
||||
switch (format) {
|
||||
case "YYYY": return String(year);
|
||||
case "YY": return String(year).slice(-2);
|
||||
case "YYYYMM": return `${year}${month}`;
|
||||
case "YYMM": return `${String(year).slice(-2)}${month}`;
|
||||
case "YYYYMMDD": return `${year}${month}${day}`;
|
||||
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
|
||||
default: return `${year}${month}${day}`;
|
||||
case "YYYY": return "[YYYY]";
|
||||
case "YY": return "[YY]";
|
||||
case "YYYYMM": return "[YYYYMM]";
|
||||
case "YYMM": return "[YYMM]";
|
||||
case "YYYYMMDD": return "[YYYYMMDD]";
|
||||
case "YYMMDD": return "[YYMMDD]";
|
||||
default: return "[DATE]";
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 문자
|
||||
case "text":
|
||||
return autoConfig.textValue || "TEXT";
|
||||
|
||||
default:
|
||||
return "XXX";
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
switch (format) {
|
||||
case "YYYY": return String(year);
|
||||
case "YY": return String(year).slice(-2);
|
||||
case "YYYYMM": return `${year}${month}`;
|
||||
case "YYMM": return `${String(year).slice(-2)}${month}`;
|
||||
case "YYYYMMDD": return `${year}${month}${day}`;
|
||||
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
|
||||
default: return `${year}${month}${day}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
case "text":
|
||||
return autoConfig.textValue || "TEXT";
|
||||
default:
|
||||
return "XXX";
|
||||
}
|
||||
});
|
||||
|
||||
return parts.join(config.separator || "");
|
||||
// 파트별 개별 구분자로 결합
|
||||
const globalSep = config.separator ?? "-";
|
||||
let result = "";
|
||||
partValues.forEach((val, idx) => {
|
||||
result += val;
|
||||
if (idx < partValues.length - 1) {
|
||||
const sep = sortedParts[idx].separatorAfter ?? globalSep;
|
||||
result += sep;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [config]);
|
||||
|
||||
if (compact) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
|||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
|
||||
interface EditModalState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -1154,19 +1155,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
if (response.success) {
|
||||
const masterRecordId = response.data?.id || formData.id;
|
||||
|
||||
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
masterRecordId,
|
||||
mainFormData: formData,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
console.log("📋 [EditModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName: screenData.screenInfo.tableName });
|
||||
|
||||
toast.success("데이터가 생성되었습니다.");
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
|
|
@ -1214,6 +1202,40 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
|
||||
try {
|
||||
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||
const handler = () => {
|
||||
clearTimeout(fallbackTimeout);
|
||||
window.removeEventListener("repeaterSaveComplete", handler);
|
||||
resolve();
|
||||
};
|
||||
window.addEventListener("repeaterSaveComplete", handler);
|
||||
});
|
||||
|
||||
console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", {
|
||||
parentId: masterRecordId,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
mainFormData: formData,
|
||||
masterRecordId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await repeaterSavePromise;
|
||||
console.log("✅ [EditModal] INSERT 후 repeaterSave 완료");
|
||||
} catch (repeaterError) {
|
||||
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} else {
|
||||
throw new Error(response.message || "생성에 실패했습니다.");
|
||||
|
|
@ -1319,6 +1341,40 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
|
||||
try {
|
||||
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||
const handler = () => {
|
||||
clearTimeout(fallbackTimeout);
|
||||
window.removeEventListener("repeaterSaveComplete", handler);
|
||||
resolve();
|
||||
};
|
||||
window.addEventListener("repeaterSaveComplete", handler);
|
||||
});
|
||||
|
||||
console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", {
|
||||
parentId: recordId,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: recordId,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
mainFormData: formData,
|
||||
masterRecordId: recordId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await repeaterSavePromise;
|
||||
console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료");
|
||||
} catch (repeaterError) {
|
||||
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} else {
|
||||
throw new Error(response.message || "수정에 실패했습니다.");
|
||||
|
|
@ -1385,12 +1441,16 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</div>
|
||||
) : screenData ? (
|
||||
<ScreenContextProvider
|
||||
screenId={modalState.screenId || undefined}
|
||||
tableName={screenData.screenInfo?.tableName}
|
||||
>
|
||||
<div
|
||||
data-screen-runtime="true"
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
width: screenDimensions?.width || 800,
|
||||
// 🆕 조건부 레이어가 활성화되면 높이 자동 확장
|
||||
// 조건부 레이어가 활성화되면 높이 자동 확장
|
||||
height: (() => {
|
||||
const baseHeight = (screenDimensions?.height || 600) + 30;
|
||||
if (activeConditionalComponents.length > 0) {
|
||||
|
|
@ -1546,6 +1606,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
</ScreenContextProvider>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
||||
|
|
|
|||
|
|
@ -571,8 +571,38 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
return;
|
||||
}
|
||||
|
||||
// 리피터가 화면과 동일 테이블을 사용하는지 감지 (useCustomTable 미설정 = 동일 테이블)
|
||||
const hasRepeaterOnSameTable = allComponents.some((c: any) => {
|
||||
const compType = c.componentType || c.overrides?.type;
|
||||
if (compType !== "v2-repeater") return false;
|
||||
const compConfig = c.componentConfig || c.overrides || {};
|
||||
return !compConfig.useCustomTable;
|
||||
});
|
||||
|
||||
if (hasRepeaterOnSameTable) {
|
||||
// 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장
|
||||
// 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: null,
|
||||
masterRecordId: null,
|
||||
mainFormData: formData,
|
||||
tableName: screenInfo.tableName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
toast.success("데이터가 성공적으로 저장되었습니다.");
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
|
||||
// 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
|
||||
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
|
||||
const masterFormData: Record<string, any> = {};
|
||||
|
||||
|
|
@ -591,11 +621,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
if (!Array.isArray(value)) {
|
||||
// 배열이 아닌 값은 그대로 저장
|
||||
masterFormData[key] = value;
|
||||
} else if (mediaColumnNames.has(key)) {
|
||||
// v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응)
|
||||
// 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용
|
||||
masterFormData[key] = value.length > 0 ? value[0] : null;
|
||||
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
|
||||
} else {
|
||||
|
|
@ -608,7 +635,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
data: masterFormData,
|
||||
};
|
||||
|
||||
// console.log("💾 저장 액션 실행:", saveData);
|
||||
const response = await dynamicFormApi.saveData(saveData);
|
||||
|
||||
if (response.success) {
|
||||
|
|
@ -619,7 +645,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
|
||||
masterRecordId,
|
||||
mainFormData: formData,
|
||||
tableName: screenInfo.tableName,
|
||||
},
|
||||
|
|
@ -631,7 +657,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
toast.error(response.message || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("저장 오류:", error);
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -551,9 +551,12 @@ export default function ScreenDesigner({
|
|||
originalRegion: null,
|
||||
});
|
||||
|
||||
// 🆕 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
|
||||
// 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
|
||||
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
|
||||
|
||||
// 다른 레이어의 컴포넌트 메타 정보 캐시 (데이터 전달 타겟 선택용)
|
||||
const [otherLayerComponents, setOtherLayerComponents] = useState<ComponentData[]>([]);
|
||||
|
||||
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
|
||||
useEffect(() => {
|
||||
if (activeLayerId <= 1 || !selectedScreen?.screenId) {
|
||||
|
|
@ -578,6 +581,41 @@ export default function ScreenDesigner({
|
|||
findZone();
|
||||
}, [activeLayerId, selectedScreen?.screenId, zones]);
|
||||
|
||||
// 다른 레이어의 컴포넌트 메타 정보 로드 (데이터 전달 타겟 선택용)
|
||||
useEffect(() => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
const loadOtherLayerComponents = async () => {
|
||||
try {
|
||||
const allLayers = await screenApi.getScreenLayers(selectedScreen.screenId);
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
const otherLayers = allLayers.filter((l: any) => l.layer_id !== currentLayerId && l.layer_id > 0);
|
||||
|
||||
const components: ComponentData[] = [];
|
||||
for (const layerInfo of otherLayers) {
|
||||
try {
|
||||
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerInfo.layer_id);
|
||||
const rawComps = layerData?.components;
|
||||
if (rawComps && Array.isArray(rawComps)) {
|
||||
for (const comp of rawComps) {
|
||||
components.push({
|
||||
...comp,
|
||||
_layerName: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
|
||||
_layerId: String(layerInfo.layer_id),
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 개별 레이어 로드 실패 무시
|
||||
}
|
||||
}
|
||||
setOtherLayerComponents(components);
|
||||
} catch {
|
||||
setOtherLayerComponents([]);
|
||||
}
|
||||
};
|
||||
loadOtherLayerComponents();
|
||||
}, [selectedScreen?.screenId, activeLayerId]);
|
||||
|
||||
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
|
||||
const visibleComponents = useMemo(() => {
|
||||
return layout.components;
|
||||
|
|
@ -3968,10 +4006,10 @@ export default function ScreenDesigner({
|
|||
label: column.columnLabel || column.columnName,
|
||||
tableName: table.tableName,
|
||||
columnName: column.columnName,
|
||||
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
|
||||
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
|
||||
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
||||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
required: isEntityJoinColumn ? false : column.required,
|
||||
readonly: false,
|
||||
parentId: formContainerId,
|
||||
componentType: v2Mapping.componentType,
|
||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
|
|
@ -3995,12 +4033,11 @@ export default function ScreenDesigner({
|
|||
},
|
||||
componentConfig: {
|
||||
type: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정
|
||||
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
|
||||
...v2Mapping.componentConfig,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용
|
||||
|
|
@ -4036,9 +4073,9 @@ export default function ScreenDesigner({
|
|||
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
|
||||
tableName: table.tableName,
|
||||
columnName: column.columnName,
|
||||
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
|
||||
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
|
||||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
required: isEntityJoinColumn ? false : column.required,
|
||||
readonly: false,
|
||||
componentType: v2Mapping.componentType,
|
||||
position: { x, y, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
|
|
@ -4062,8 +4099,7 @@ export default function ScreenDesigner({
|
|||
},
|
||||
componentConfig: {
|
||||
type: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정
|
||||
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
|
||||
...v2Mapping.componentConfig,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -6518,8 +6554,8 @@ export default function ScreenDesigner({
|
|||
updateComponentProperty(selectedComponent.id, "style", style);
|
||||
}
|
||||
}}
|
||||
allComponents={layout.components} // 🆕 플로우 위젯 감지용
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||
allComponents={[...layout.components, ...otherLayerComponents]}
|
||||
menuObjid={menuObjid}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -92,13 +92,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
|
||||
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
|
||||
|
||||
// 🆕 데이터 전달 필드 매핑용 상태
|
||||
const [mappingSourceColumns, setMappingSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
// 🆕 데이터 전달 필드 매핑용 상태 (멀티 테이블 매핑 지원)
|
||||
const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
|
||||
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({});
|
||||
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({});
|
||||
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<string, boolean>>({});
|
||||
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<string, boolean>>({});
|
||||
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<string, string>>({});
|
||||
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<string, string>>({});
|
||||
const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0);
|
||||
|
||||
// 🆕 openModalWithData 전용 필드 매핑 상태
|
||||
const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
|
|
@ -295,57 +296,57 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const sourceTable = config.action?.dataTransfer?.sourceTable;
|
||||
const targetTable = config.action?.dataTransfer?.targetTable;
|
||||
// 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드
|
||||
const loadMappingColumns = useCallback(async (tableName: string): Promise<Array<{ name: string; label: string }>> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
if (response.data.success) {
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
const loadColumns = async () => {
|
||||
if (sourceTable) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`);
|
||||
if (response.data.success) {
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setMappingSourceColumns(columns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("소스 테이블 컬럼 로드 실패:", error);
|
||||
if (Array.isArray(columnData)) {
|
||||
return columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||
}
|
||||
return [];
|
||||
}, []);
|
||||
|
||||
if (targetTable) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`);
|
||||
if (response.data.success) {
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
useEffect(() => {
|
||||
const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || [];
|
||||
const legacySourceTable = config.action?.dataTransfer?.sourceTable;
|
||||
const targetTable = config.action?.dataTransfer?.targetTable;
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setMappingTargetColumns(columns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("타겟 테이블 컬럼 로드 실패:", error);
|
||||
const loadAll = async () => {
|
||||
const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean);
|
||||
if (legacySourceTable && !sourceTableNames.includes(legacySourceTable)) {
|
||||
sourceTableNames.push(legacySourceTable);
|
||||
}
|
||||
|
||||
const newMap: Record<string, Array<{ name: string; label: string }>> = {};
|
||||
for (const tbl of sourceTableNames) {
|
||||
if (!mappingSourceColumnsMap[tbl]) {
|
||||
newMap[tbl] = await loadMappingColumns(tbl);
|
||||
}
|
||||
}
|
||||
if (Object.keys(newMap).length > 0) {
|
||||
setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap }));
|
||||
}
|
||||
|
||||
if (targetTable && mappingTargetColumns.length === 0) {
|
||||
const cols = await loadMappingColumns(targetTable);
|
||||
setMappingTargetColumns(cols);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
|
||||
loadAll();
|
||||
}, [config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable, loadMappingColumns]);
|
||||
|
||||
// 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -2966,11 +2967,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 데이터 제공 가능한 컴포넌트 필터링 */}
|
||||
{/* 자동 탐색 옵션 (레이어별 테이블이 다를 때 유용) */}
|
||||
<SelectItem value="__auto__">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">자동 탐색 (현재 활성 테이블)</span>
|
||||
<span className="text-muted-foreground text-[10px]">(auto)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{/* 데이터 제공 가능한 컴포넌트 필터링 (모든 레이어 포함) */}
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
// 데이터를 제공할 수 있는 컴포넌트 타입들
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||
type.includes(t),
|
||||
);
|
||||
|
|
@ -2978,11 +2985,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||
const layerName = comp._layerName;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||
{layerName && (
|
||||
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
{layerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
|
|
@ -2999,7 +3012,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-xs">테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -3037,33 +3052,47 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||
onValueChange={(value) =>
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value)
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value);
|
||||
// 선택한 컴포넌트가 다른 레이어에 있으면 targetLayerId도 저장
|
||||
const selectedComp = allComponents.find((c: any) => c.id === value);
|
||||
if (selectedComp && (selectedComp as any)._layerId) {
|
||||
onUpdateProperty(
|
||||
"componentConfig.action.dataTransfer.targetLayerId",
|
||||
(selectedComp as any)._layerId,
|
||||
);
|
||||
} else {
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.targetLayerId", undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 데이터 수신 가능한 컴포넌트 필터링 (소스와 다른 컴포넌트만) */}
|
||||
{/* 데이터 수신 가능한 컴포넌트 필터링 (모든 레이어 포함, 소스와 다른 컴포넌트만) */}
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
// 데이터를 받을 수 있는 컴포넌트 타입들
|
||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(t) => type.includes(t),
|
||||
);
|
||||
// 소스와 다른 컴포넌트만
|
||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||
const layerName = comp._layerName;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||
{layerName && (
|
||||
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
{layerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
|
|
@ -3261,301 +3290,417 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
<div>
|
||||
<Label htmlFor="additional-field-name" className="text-xs">
|
||||
필드명 (선택사항)
|
||||
타겟 필드명 (선택사항)
|
||||
</Label>
|
||||
<Input
|
||||
id="additional-field-name"
|
||||
placeholder="예: inbound_type (비워두면 전체 데이터)"
|
||||
value={config.action?.dataTransfer?.additionalSources?.[0]?.fieldName || ""}
|
||||
onChange={(e) => {
|
||||
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||
const newSources = [...currentSources];
|
||||
if (newSources.length === 0) {
|
||||
newSources.push({ componentId: "", fieldName: e.target.value });
|
||||
} else {
|
||||
newSources[0] = { ...newSources[0], fieldName: e.target.value };
|
||||
}
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">타겟 테이블에 저장될 필드명</p>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{(() => {
|
||||
const fieldName = config.action?.dataTransfer?.additionalSources?.[0]?.fieldName;
|
||||
if (!fieldName) return "필드 선택 (비워두면 전체 데이터)";
|
||||
const cols = mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns;
|
||||
const found = cols.find((c) => c.name === fieldName);
|
||||
return found ? `${found.label || found.name}` : fieldName;
|
||||
})()}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||
const newSources = [...currentSources];
|
||||
if (newSources.length === 0) {
|
||||
newSources.push({ componentId: "", fieldName: "" });
|
||||
} else {
|
||||
newSources[0] = { ...newSources[0], fieldName: "" };
|
||||
}
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", !config.action?.dataTransfer?.additionalSources?.[0]?.fieldName ? "opacity-100" : "opacity-0")} />
|
||||
<span className="text-muted-foreground">선택 안 함 (전체 데이터 병합)</span>
|
||||
</CommandItem>
|
||||
{(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label || ""} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||
const newSources = [...currentSources];
|
||||
if (newSources.length === 0) {
|
||||
newSources.push({ componentId: "", fieldName: col.name });
|
||||
} else {
|
||||
newSources[0] = { ...newSources[0], fieldName: col.name };
|
||||
}
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", config.action?.dataTransfer?.additionalSources?.[0]?.fieldName === col.name ? "opacity-100" : "opacity-0")} />
|
||||
<span className="font-medium">{col.label || col.name}</span>
|
||||
{col.label && col.label !== col.name && (
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground mt-1 text-xs">추가 데이터가 저장될 타겟 테이블 컬럼</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 규칙 */}
|
||||
{/* 멀티 테이블 필드 매핑 */}
|
||||
<div className="space-y-3">
|
||||
<Label>필드 매핑 설정</Label>
|
||||
|
||||
{/* 소스/타겟 테이블 선택 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">소스 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||
{config.action?.dataTransfer?.sourceTable
|
||||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.sourceTable)?.label ||
|
||||
config.action?.dataTransfer?.sourceTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={() => {
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.action?.dataTransfer?.sourceTable === table.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||
{config.action?.dataTransfer?.targetTable
|
||||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
|
||||
config.action?.dataTransfer?.targetTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={() => {
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{/* 타겟 테이블 (공통) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||
{config.action?.dataTransfer?.targetTable
|
||||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
|
||||
config.action?.dataTransfer?.targetTable
|
||||
: "타겟 테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={() => {
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 규칙 */}
|
||||
{/* 소스 테이블 매핑 그룹 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">필드 매핑 규칙</Label>
|
||||
<Label className="text-xs">소스 테이블별 매핑</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => {
|
||||
const currentRules = config.action?.dataTransfer?.mappingRules || [];
|
||||
const newRule = { sourceField: "", targetField: "", transform: "" };
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", [...currentRules, newRule]);
|
||||
const currentMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", [
|
||||
...currentMappings,
|
||||
{ sourceTable: "", mappingRules: [] },
|
||||
]);
|
||||
setActiveMappingGroupIndex(currentMappings.length);
|
||||
}}
|
||||
disabled={!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable}
|
||||
disabled={!config.action?.dataTransfer?.targetTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
소스 테이블 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다.
|
||||
여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.
|
||||
</p>
|
||||
|
||||
{!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable ? (
|
||||
{!config.action?.dataTransfer?.targetTable ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-muted-foreground text-xs">먼저 소스 테이블과 타겟 테이블을 선택하세요.</p>
|
||||
<p className="text-muted-foreground text-xs">먼저 타겟 테이블을 선택하세요.</p>
|
||||
</div>
|
||||
) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? (
|
||||
) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다.
|
||||
매핑 그룹이 없습니다. 소스 테이블을 추가하세요.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => (
|
||||
<div key={index} className="bg-background flex items-center gap-2 rounded-md border p-2">
|
||||
{/* 소스 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={mappingSourcePopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
{/* 소스 테이블 탭 */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => (
|
||||
<div key={gIdx} className="flex items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => setActiveMappingGroupIndex(gIdx)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
||||
{rule.sourceField
|
||||
? mappingSourceColumns.find((c) => c.name === rule.sourceField)?.label ||
|
||||
rule.sourceField
|
||||
: "소스 필드"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
className="h-8 text-xs"
|
||||
value={mappingSourceSearch[index] || ""}
|
||||
onValueChange={(value) =>
|
||||
setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))
|
||||
}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
컬럼을 찾을 수 없습니다
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mappingSourceColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
||||
rules[index] = { ...rules[index], sourceField: col.name };
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
||||
setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
rule.sourceField === col.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="text-muted-foreground ml-1">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
|
||||
{/* 타겟 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={mappingTargetPopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
{group.sourceTable
|
||||
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
|
||||
: `그룹 ${gIdx + 1}`}
|
||||
{group.mappingRules?.length > 0 && (
|
||||
<span className="bg-primary/20 ml-1 rounded-full px-1 text-[9px]">
|
||||
{group.mappingRules.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10 h-5 w-5"
|
||||
onClick={() => {
|
||||
const mappings = [...(config.action?.dataTransfer?.multiTableMappings || [])];
|
||||
mappings.splice(gIdx, 1);
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||||
if (activeMappingGroupIndex >= mappings.length) {
|
||||
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
||||
{rule.targetField
|
||||
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label ||
|
||||
rule.targetField
|
||||
: "타겟 필드"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
className="h-8 text-xs"
|
||||
value={mappingTargetSearch[index] || ""}
|
||||
onValueChange={(value) =>
|
||||
setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))
|
||||
}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
컬럼을 찾을 수 없습니다
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mappingTargetColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
||||
rules[index] = { ...rules[index], targetField: col.name };
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
||||
setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
rule.targetField === col.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="text-muted-foreground ml-1">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
||||
onClick={() => {
|
||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
||||
rules.splice(index, 1);
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{/* 활성 그룹 편집 영역 */}
|
||||
{(() => {
|
||||
const multiMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||||
const activeGroup = multiMappings[activeMappingGroupIndex];
|
||||
if (!activeGroup) return null;
|
||||
|
||||
const activeSourceTable = activeGroup.sourceTable || "";
|
||||
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
|
||||
const activeRules: any[] = activeGroup.mappingRules || [];
|
||||
|
||||
const updateGroupField = (field: string, value: any) => {
|
||||
const mappings = [...multiMappings];
|
||||
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
{/* 소스 테이블 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">소스 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||
{activeSourceTable
|
||||
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
|
||||
: "소스 테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={async () => {
|
||||
updateGroupField("sourceTable", table.name);
|
||||
if (!mappingSourceColumnsMap[table.name]) {
|
||||
const cols = await loadMappingColumns(table.name);
|
||||
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
activeSourceTable === table.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 매핑 규칙 목록 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px]">매핑 규칙</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-5 text-[10px]"
|
||||
onClick={() => {
|
||||
updateGroupField("mappingRules", [...activeRules, { sourceField: "", targetField: "" }]);
|
||||
}}
|
||||
disabled={!activeSourceTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!activeSourceTable ? (
|
||||
<p className="text-muted-foreground text-[10px]">소스 테이블을 먼저 선택하세요.</p>
|
||||
) : activeRules.length === 0 ? (
|
||||
<p className="text-muted-foreground text-[10px]">매핑 없음 (동일 필드명 자동 매핑)</p>
|
||||
) : (
|
||||
activeRules.map((rule: any, rIdx: number) => {
|
||||
const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`;
|
||||
const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`;
|
||||
return (
|
||||
<div key={rIdx} className="bg-background flex items-center gap-2 rounded-md border p-2">
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={mappingSourcePopoverOpen[popoverKeyS] || false}
|
||||
onOpenChange={(open) =>
|
||||
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open }))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
||||
{rule.sourceField
|
||||
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
|
||||
: "소스 필드"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{activeSourceColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const newRules = [...activeRules];
|
||||
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
|
||||
updateGroupField("mappingRules", newRules);
|
||||
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", rule.sourceField === col.name ? "opacity-100" : "opacity-0")} />
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && <span className="text-muted-foreground ml-1">({col.name})</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={mappingTargetPopoverOpen[popoverKeyT] || false}
|
||||
onOpenChange={(open) =>
|
||||
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open }))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
||||
{rule.targetField
|
||||
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
|
||||
: "타겟 필드"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mappingTargetColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const newRules = [...activeRules];
|
||||
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
|
||||
updateGroupField("mappingRules", newRules);
|
||||
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", rule.targetField === col.name ? "opacity-100" : "opacity-0")} />
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && <span className="text-muted-foreground ml-1">({col.name})</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
||||
onClick={() => {
|
||||
const newRules = [...activeRules];
|
||||
newRules.splice(rIdx, 1);
|
||||
updateGroupField("mappingRules", newRules);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -3567,9 +3712,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<br />
|
||||
1. 소스 컴포넌트에서 데이터를 선택합니다
|
||||
<br />
|
||||
2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드)
|
||||
2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
|
||||
<br />
|
||||
3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다
|
||||
3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
|
||||
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
|
||||
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel,
|
||||
"v2-bom-tree": require("@/components/v2/config-panels/V2BomTreeConfigPanel").V2BomTreeConfigPanel,
|
||||
};
|
||||
|
||||
const V2ConfigPanel = v2ConfigPanels[componentId];
|
||||
|
|
@ -236,11 +237,13 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
const extraProps: Record<string, any> = {};
|
||||
if (componentId === "v2-select") {
|
||||
extraProps.inputType = inputType;
|
||||
extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||
extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
|
||||
}
|
||||
if (componentId === "v2-list") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
}
|
||||
if (componentId === "v2-bom-item-editor") {
|
||||
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,9 +72,10 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
allColumns = response.data;
|
||||
}
|
||||
|
||||
// category 타입 컬럼만 필터링
|
||||
// category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외)
|
||||
const categoryColumns = allColumns.filter(
|
||||
(col: any) => col.inputType === "category" || col.input_type === "category"
|
||||
(col: any) => (col.inputType === "category" || col.input_type === "category")
|
||||
&& !col.categoryRef && !col.category_ref
|
||||
);
|
||||
|
||||
console.log("✅ 카테고리 컬럼 필터링 완료:", {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import {
|
|||
import { apiClient } from "@/lib/api/client";
|
||||
import { allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { DataReceivable } from "@/types/data-transfer";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// modal-repeater-table 컴포넌트 재사용
|
||||
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
|
||||
|
|
@ -38,6 +41,7 @@ declare global {
|
|||
|
||||
export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
config: propConfig,
|
||||
componentId,
|
||||
parentId,
|
||||
data: initialData,
|
||||
onDataChange,
|
||||
|
|
@ -48,6 +52,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
}) => {
|
||||
// ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용)
|
||||
const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData;
|
||||
|
||||
// componentId 결정: 직접 전달 또는 component 객체에서 추출
|
||||
const effectiveComponentId = componentId || (restProps as any).component?.id;
|
||||
|
||||
// ScreenContext 연동 (DataReceiver 등록, Provider 없으면 null)
|
||||
const screenContext = useScreenContextOptional();
|
||||
// 설정 병합
|
||||
const config: V2RepeaterConfig = useMemo(
|
||||
() => ({
|
||||
|
|
@ -65,9 +75,119 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// 저장 이벤트 핸들러에서 항상 최신 data를 참조하기 위한 ref
|
||||
const dataRef = useRef<any[]>(data);
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
// 수정 모드에서 로드된 원본 ID 목록 (삭제 추적용)
|
||||
const loadedIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
||||
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
||||
|
||||
// ScreenContext DataReceiver 등록 (데이터 전달 액션 수신)
|
||||
const onDataChangeRef = useRef(onDataChange);
|
||||
onDataChangeRef.current = onDataChange;
|
||||
|
||||
const handleReceiveData = useCallback(
|
||||
async (incomingData: any[], configOrMode?: any) => {
|
||||
console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode });
|
||||
|
||||
if (!incomingData || incomingData.length === 0) {
|
||||
toast.warning("전달할 데이터가 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거
|
||||
const metaFieldsToStrip = new Set([
|
||||
"id",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"company_code",
|
||||
]);
|
||||
const normalizedData = incomingData.map((item: any) => {
|
||||
let raw = item;
|
||||
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
|
||||
const { 0: originalData, ...additionalFields } = item;
|
||||
raw = { ...originalData, ...additionalFields };
|
||||
}
|
||||
const cleaned: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!metaFieldsToStrip.has(key)) {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
});
|
||||
|
||||
const mode = configOrMode?.mode || configOrMode || "append";
|
||||
|
||||
// 카테고리 코드 → 라벨 변환
|
||||
// allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환
|
||||
const codesToResolve = new Set<string>();
|
||||
for (const item of normalizedData) {
|
||||
for (const [key, val] of Object.entries(item)) {
|
||||
if (key.startsWith("_")) continue;
|
||||
if (typeof val === "string" && val && !categoryLabelMapRef.current[val]) {
|
||||
codesToResolve.add(val as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (codesToResolve.size > 0) {
|
||||
try {
|
||||
const resp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||
valueCodes: Array.from(codesToResolve),
|
||||
});
|
||||
if (resp.data?.success && resp.data.data) {
|
||||
const labelData = resp.data.data as Record<string, string>;
|
||||
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||
for (const item of normalizedData) {
|
||||
for (const key of Object.keys(item)) {
|
||||
if (key.startsWith("_")) continue;
|
||||
const val = item[key];
|
||||
if (typeof val === "string" && labelData[val]) {
|
||||
item[key] = labelData[val];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 변환 실패 시 코드 유지
|
||||
}
|
||||
}
|
||||
|
||||
setData((prev) => {
|
||||
const next = mode === "replace" ? normalizedData : [...prev, ...normalizedData];
|
||||
onDataChangeRef.current?.(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (screenContext && effectiveComponentId) {
|
||||
const receiver: DataReceivable = {
|
||||
componentId: effectiveComponentId,
|
||||
componentType: "v2-repeater",
|
||||
receiveData: handleReceiveData,
|
||||
};
|
||||
console.log("📋 [V2Repeater] ScreenContext에 데이터 수신자 등록:", effectiveComponentId);
|
||||
screenContext.registerDataReceiver(effectiveComponentId, receiver);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataReceiver(effectiveComponentId);
|
||||
};
|
||||
}
|
||||
}, [screenContext, effectiveComponentId, handleReceiveData]);
|
||||
|
||||
// 소스 테이블 컬럼 라벨 매핑
|
||||
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
||||
|
||||
|
|
@ -76,6 +196,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
|
||||
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
|
||||
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
||||
const categoryLabelMapRef = useRef<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
categoryLabelMapRef.current = categoryLabelMap;
|
||||
}, [categoryLabelMap]);
|
||||
|
||||
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
||||
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
||||
|
|
@ -109,35 +233,54 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
};
|
||||
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
|
||||
|
||||
// 저장 이벤트 리스너
|
||||
// 저장 이벤트 리스너 (dataRef/categoryLabelMapRef를 사용하여 항상 최신 상태 참조)
|
||||
useEffect(() => {
|
||||
const handleSaveEvent = async (event: CustomEvent) => {
|
||||
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
|
||||
const tableName =
|
||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||
const eventParentId = event.detail?.parentId;
|
||||
const mainFormData = event.detail?.mainFormData;
|
||||
const currentData = dataRef.current;
|
||||
const currentCategoryMap = categoryLabelMapRef.current;
|
||||
|
||||
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
|
||||
const configTableName =
|
||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||
const tableName = configTableName || event.detail?.tableName;
|
||||
const mainFormData = event.detail?.mainFormData;
|
||||
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
|
||||
|
||||
if (!tableName || data.length === 0) {
|
||||
console.log("🔵 [V2Repeater] repeaterSave 이벤트 수신:", {
|
||||
configTableName,
|
||||
tableName,
|
||||
masterRecordId,
|
||||
dataLength: currentData.length,
|
||||
foreignKeyColumn: config.foreignKeyColumn,
|
||||
foreignKeySourceColumn: config.foreignKeySourceColumn,
|
||||
dataSnapshot: currentData.map((r: any) => ({ id: r.id, item_name: r.item_name })),
|
||||
});
|
||||
toast.info(`[디버그] V2Repeater 이벤트 수신: ${currentData.length}건, table=${tableName}`);
|
||||
|
||||
if (!tableName || currentData.length === 0) {
|
||||
console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length });
|
||||
toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`);
|
||||
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
||||
return;
|
||||
}
|
||||
|
||||
// V2Repeater 저장 시작
|
||||
const saveInfo = {
|
||||
if (config.foreignKeyColumn) {
|
||||
const sourceCol = config.foreignKeySourceColumn;
|
||||
const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined;
|
||||
if (!hasFkSource && !masterRecordId) {
|
||||
console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵");
|
||||
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("V2Repeater 저장 시작", {
|
||||
tableName,
|
||||
useCustomTable: config.useCustomTable,
|
||||
mainTableName: config.mainTableName,
|
||||
foreignKeyColumn: config.foreignKeyColumn,
|
||||
masterRecordId,
|
||||
dataLength: data.length,
|
||||
};
|
||||
console.log("V2Repeater 저장 시작", saveInfo);
|
||||
dataLength: currentData.length,
|
||||
});
|
||||
|
||||
try {
|
||||
// 테이블 유효 컬럼 조회
|
||||
let validColumns: Set<string> = new Set();
|
||||
try {
|
||||
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
|
|
@ -148,13 +291,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
console.warn("테이블 컬럼 정보 조회 실패");
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
|
||||
// 내부 필드 제거
|
||||
for (let i = 0; i < currentData.length; i++) {
|
||||
const row = currentData[i];
|
||||
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
|
||||
|
||||
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
|
||||
let mergedData: Record<string, any>;
|
||||
if (config.useCustomTable && config.mainTableName) {
|
||||
mergedData = { ...cleanRow };
|
||||
|
|
@ -181,59 +321,83 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
};
|
||||
}
|
||||
|
||||
// 유효하지 않은 컬럼 제거
|
||||
const filteredData: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(mergedData)) {
|
||||
if (validColumns.size === 0 || validColumns.has(key)) {
|
||||
filteredData[key] = value;
|
||||
if (typeof value === "string" && currentCategoryMap[value]) {
|
||||
filteredData[key] = currentCategoryMap[value];
|
||||
} else {
|
||||
filteredData[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 행(id 존재)은 UPDATE, 새 행은 INSERT
|
||||
const rowId = row.id;
|
||||
console.log(`🔧 [V2Repeater] 행 ${i} 저장:`, {
|
||||
rowId,
|
||||
isUpdate: rowId && typeof rowId === "string" && rowId.includes("-"),
|
||||
filteredDataKeys: Object.keys(filteredData),
|
||||
});
|
||||
if (rowId && typeof rowId === "string" && rowId.includes("-")) {
|
||||
// UUID 형태의 id가 있으면 기존 데이터 → UPDATE
|
||||
const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData;
|
||||
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
|
||||
originalData: { id: rowId },
|
||||
updatedData: updateFields,
|
||||
});
|
||||
} else {
|
||||
// 새 행 → INSERT
|
||||
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
|
||||
}
|
||||
}
|
||||
// 삭제된 행 처리: 원본에는 있었지만 현재 data에 없는 ID를 DELETE
|
||||
const currentIds = new Set(currentData.map((r) => r.id).filter(Boolean));
|
||||
const deletedIds = Array.from(loadedIdsRef.current).filter((id) => !currentIds.has(id));
|
||||
if (deletedIds.length > 0) {
|
||||
console.log("🗑️ [V2Repeater] 삭제할 행:", deletedIds);
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${tableName}/delete`, {
|
||||
data: deletedIds.map((id) => ({ id })),
|
||||
});
|
||||
console.log(`✅ [V2Repeater] ${deletedIds.length}건 삭제 완료`);
|
||||
} catch (deleteError) {
|
||||
console.error("❌ [V2Repeater] 삭제 실패:", deleteError);
|
||||
}
|
||||
}
|
||||
|
||||
// 저장 완료 후 loadedIdsRef 갱신
|
||||
loadedIdsRef.current = new Set(currentData.map((r) => r.id).filter(Boolean));
|
||||
|
||||
toast.success(`V2Repeater ${currentData.length}건 저장 완료`);
|
||||
} catch (error) {
|
||||
console.error("❌ V2Repeater 저장 실패:", error);
|
||||
throw error;
|
||||
toast.error(`V2Repeater 저장 실패: ${error}`);
|
||||
} finally {
|
||||
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
|
||||
}
|
||||
};
|
||||
|
||||
// V2 EventBus 구독
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.REPEATER_SAVE,
|
||||
async (payload) => {
|
||||
const tableName =
|
||||
const configTableName =
|
||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||
if (payload.tableName === tableName) {
|
||||
if (!configTableName || payload.tableName === configTableName) {
|
||||
await handleSaveEvent({ detail: payload } as CustomEvent);
|
||||
}
|
||||
},
|
||||
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
|
||||
{ componentId: `v2-repeater-${config.dataSource?.tableName || "same-table"}` },
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
window.addEventListener("repeaterSave" as any, handleSaveEvent);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
|
||||
};
|
||||
}, [
|
||||
data,
|
||||
config.dataSource?.tableName,
|
||||
config.useCustomTable,
|
||||
config.mainTableName,
|
||||
config.foreignKeyColumn,
|
||||
config.foreignKeySourceColumn,
|
||||
parentId,
|
||||
]);
|
||||
|
||||
|
|
@ -301,7 +465,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
});
|
||||
|
||||
// 각 행에 소스 테이블의 표시 데이터 병합
|
||||
// RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함
|
||||
rows.forEach((row: any) => {
|
||||
const sourceRecord = sourceMap.get(String(row[fkColumn]));
|
||||
if (sourceRecord) {
|
||||
|
|
@ -319,12 +482,50 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환
|
||||
const codesToResolve = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const val of Object.values(row)) {
|
||||
if (typeof val === "string" && val.startsWith("CATEGORY_")) {
|
||||
codesToResolve.add(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (codesToResolve.size > 0) {
|
||||
try {
|
||||
const labelResp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||
valueCodes: Array.from(codesToResolve),
|
||||
});
|
||||
if (labelResp.data?.success && labelResp.data.data) {
|
||||
const labelData = labelResp.data.data as Record<string, string>;
|
||||
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||
for (const row of rows) {
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key.startsWith("_")) continue;
|
||||
const val = row[key];
|
||||
if (typeof val === "string" && labelData[val]) {
|
||||
row[key] = labelData[val];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 라벨 변환 실패 시 코드 유지
|
||||
}
|
||||
}
|
||||
|
||||
// 원본 ID 목록 기록 (삭제 추적용)
|
||||
const ids = rows.map((r: any) => r.id).filter(Boolean);
|
||||
loadedIdsRef.current = new Set(ids);
|
||||
console.log("📋 [V2Repeater] 원본 ID 기록:", ids);
|
||||
|
||||
setData(rows);
|
||||
dataLoadedRef.current = true;
|
||||
if (onDataChange) onDataChange(rows);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error);
|
||||
console.error("[V2Repeater] 기존 데이터 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -346,16 +547,28 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
if (!tableName) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
||||
const [colResponse, typeResponse] = await Promise.all([
|
||||
apiClient.get(`/table-management/tables/${tableName}/columns`),
|
||||
apiClient.get(`/table-management/tables/${tableName}/web-types`),
|
||||
]);
|
||||
const columns = colResponse.data?.data?.columns || colResponse.data?.columns || colResponse.data || [];
|
||||
const inputTypes = typeResponse.data?.data || [];
|
||||
|
||||
// inputType/categoryRef 매핑 생성
|
||||
const typeMap: Record<string, any> = {};
|
||||
inputTypes.forEach((t: any) => {
|
||||
typeMap[t.columnName] = t;
|
||||
});
|
||||
|
||||
const columnMap: Record<string, any> = {};
|
||||
columns.forEach((col: any) => {
|
||||
const name = col.columnName || col.column_name || col.name;
|
||||
const typeInfo = typeMap[name];
|
||||
columnMap[name] = {
|
||||
inputType: col.inputType || col.input_type || col.webType || "text",
|
||||
inputType: typeInfo?.inputType || col.inputType || col.input_type || col.webType || "text",
|
||||
displayName: col.displayName || col.display_name || col.label || name,
|
||||
detailSettings: col.detailSettings || col.detail_settings,
|
||||
categoryRef: typeInfo?.categoryRef || null,
|
||||
};
|
||||
});
|
||||
setCurrentTableColumnInfo(columnMap);
|
||||
|
|
@ -487,14 +700,18 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
else if (inputType === "code") type = "select";
|
||||
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
|
||||
|
||||
// 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
|
||||
// category 타입인 경우 현재 테이블명과 컬럼명을 조합
|
||||
// 카테고리 참조 ID 결정
|
||||
// DB의 category_ref 설정 우선, 없으면 자기 테이블.컬럼명 사용
|
||||
let categoryRef: string | undefined;
|
||||
if (inputType === "category") {
|
||||
// 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용
|
||||
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
|
||||
if (tableName) {
|
||||
categoryRef = `${tableName}.${col.key}`;
|
||||
const dbCategoryRef = colInfo?.detailSettings?.categoryRef || colInfo?.categoryRef;
|
||||
if (dbCategoryRef) {
|
||||
categoryRef = dbCategoryRef;
|
||||
} else {
|
||||
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
|
||||
if (tableName) {
|
||||
categoryRef = `${tableName}.${col.key}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -512,55 +729,79 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
});
|
||||
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
|
||||
|
||||
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
|
||||
// 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지
|
||||
// repeaterColumns의 resolved type 사용 (config + DB 메타데이터 모두 반영)
|
||||
const allCategoryColumns = useMemo(() => {
|
||||
const fromRepeater = repeaterColumns
|
||||
.filter((col) => col.type === "category")
|
||||
.map((col) => col.field.replace(/^_display_/, ""));
|
||||
const merged = new Set([...sourceCategoryColumns, ...fromRepeater]);
|
||||
return Array.from(merged);
|
||||
}, [sourceCategoryColumns, repeaterColumns]);
|
||||
|
||||
// CATEGORY_ 코드 배열을 받아 라벨을 일괄 조회하는 함수
|
||||
const fetchCategoryLabels = useCallback(async (codes: string[]) => {
|
||||
if (codes.length === 0) return;
|
||||
try {
|
||||
const response = await apiClient.post("/table-categories/labels-by-codes", {
|
||||
valueCodes: codes,
|
||||
});
|
||||
if (response.data?.success && response.data.data) {
|
||||
setCategoryLabelMap((prev) => ({ ...prev, ...response.data.data }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 라벨 조회 실패:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// parentFormData(마스터 행)에서 카테고리 코드를 미리 로드
|
||||
// fromMainForm autoFill에서 참조할 마스터 필드의 라벨을 사전에 확보
|
||||
useEffect(() => {
|
||||
const loadCategoryLabels = async () => {
|
||||
if (sourceCategoryColumns.length === 0 || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!parentFormData) return;
|
||||
const codes: string[] = [];
|
||||
|
||||
// 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
|
||||
const allCodes = new Set<string>();
|
||||
for (const row of data) {
|
||||
for (const col of sourceCategoryColumns) {
|
||||
// _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인
|
||||
const val = row[`_display_${col}`] || row[col];
|
||||
if (val && typeof val === "string") {
|
||||
const codes = val
|
||||
.split(",")
|
||||
.map((c: string) => c.trim())
|
||||
.filter(Boolean);
|
||||
for (const code of codes) {
|
||||
if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) {
|
||||
allCodes.add(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
// fromMainForm autoFill의 sourceField 값 중 카테고리 컬럼에 해당하는 것만 수집
|
||||
for (const col of config.columns) {
|
||||
if (col.autoFill?.type === "fromMainForm" && col.autoFill.sourceField) {
|
||||
const val = parentFormData[col.autoFill.sourceField];
|
||||
if (typeof val === "string" && val && !categoryLabelMap[val]) {
|
||||
codes.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
if (allCodes.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post("/table-categories/labels-by-codes", {
|
||||
valueCodes: Array.from(allCodes),
|
||||
});
|
||||
|
||||
if (response.data?.success && response.data.data) {
|
||||
setCategoryLabelMap((prev) => ({
|
||||
...prev,
|
||||
...response.data.data,
|
||||
}));
|
||||
// receiveFromParent 패턴
|
||||
if ((col as any).receiveFromParent) {
|
||||
const parentField = (col as any).parentFieldName || col.key;
|
||||
const val = parentFormData[parentField];
|
||||
if (typeof val === "string" && val && !categoryLabelMap[val]) {
|
||||
codes.push(val);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 라벨 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
loadCategoryLabels();
|
||||
}, [data, sourceCategoryColumns]);
|
||||
if (codes.length > 0) {
|
||||
fetchCategoryLabels(codes);
|
||||
}
|
||||
}, [parentFormData, config.columns, fetchCategoryLabels]);
|
||||
|
||||
// 데이터 변경 시 카테고리 라벨 로드
|
||||
useEffect(() => {
|
||||
if (data.length === 0) return;
|
||||
|
||||
const allCodes = new Set<string>();
|
||||
|
||||
for (const row of data) {
|
||||
for (const col of allCategoryColumns) {
|
||||
const val = row[`_display_${col}`] || row[col];
|
||||
if (val && typeof val === "string") {
|
||||
val.split(",").map((c: string) => c.trim()).filter(Boolean).forEach((code: string) => {
|
||||
if (!categoryLabelMap[code]) allCodes.add(code);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchCategoryLabels(Array.from(allCodes));
|
||||
}, [data, allCategoryColumns, fetchCategoryLabels]);
|
||||
|
||||
// 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
|
||||
const applyCalculationRules = useCallback(
|
||||
|
|
@ -677,7 +918,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
|
||||
case "fromMainForm":
|
||||
if (col.autoFill.sourceField && mainFormData) {
|
||||
return mainFormData[col.autoFill.sourceField];
|
||||
const rawValue = mainFormData[col.autoFill.sourceField];
|
||||
// categoryLabelMap에 매핑이 있으면 라벨로 변환 (접두사 무관)
|
||||
if (typeof rawValue === "string" && categoryLabelMap[rawValue]) {
|
||||
return categoryLabelMap[rawValue];
|
||||
}
|
||||
return rawValue;
|
||||
}
|
||||
return "";
|
||||
|
||||
|
|
@ -697,7 +943,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
return undefined;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[categoryLabelMap],
|
||||
);
|
||||
|
||||
// 🆕 채번 API 호출 (비동기)
|
||||
|
|
@ -731,7 +977,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
const row: any = { _id: `grouped_${Date.now()}_${index}` };
|
||||
|
||||
for (const col of config.columns) {
|
||||
const sourceValue = item[(col as any).sourceKey || col.key];
|
||||
let sourceValue = item[(col as any).sourceKey || col.key];
|
||||
|
||||
// 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반)
|
||||
if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) {
|
||||
sourceValue = categoryLabelMap[sourceValue];
|
||||
}
|
||||
|
||||
if (col.isSourceDisplay) {
|
||||
row[col.key] = sourceValue ?? "";
|
||||
|
|
@ -752,6 +1003,48 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
return row;
|
||||
});
|
||||
|
||||
// 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관)
|
||||
const categoryColSet = new Set(allCategoryColumns);
|
||||
const codesToResolve = new Set<string>();
|
||||
for (const row of newRows) {
|
||||
for (const col of config.columns) {
|
||||
const val = row[col.key] || row[`_display_${col.key}`];
|
||||
if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) {
|
||||
if (!categoryLabelMap[val]) {
|
||||
codesToResolve.add(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (codesToResolve.size > 0) {
|
||||
apiClient.post("/table-categories/labels-by-codes", {
|
||||
valueCodes: Array.from(codesToResolve),
|
||||
}).then((resp) => {
|
||||
if (resp.data?.success && resp.data.data) {
|
||||
const labelData = resp.data.data as Record<string, string>;
|
||||
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||
const convertedRows = newRows.map((row) => {
|
||||
const updated = { ...row };
|
||||
for (const col of config.columns) {
|
||||
const val = updated[col.key];
|
||||
if (typeof val === "string" && labelData[val]) {
|
||||
updated[col.key] = labelData[val];
|
||||
}
|
||||
const dispKey = `_display_${col.key}`;
|
||||
const dispVal = updated[dispKey];
|
||||
if (typeof dispVal === "string" && labelData[dispVal]) {
|
||||
updated[dispKey] = labelData[dispVal];
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
setData(convertedRows);
|
||||
onDataChange?.(convertedRows);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
setData(newRows);
|
||||
onDataChange?.(newRows);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
|
@ -786,7 +1079,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentFormData, config.columns, generateAutoFillValueSync]);
|
||||
|
||||
// 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
|
||||
// 행 추가 (inline 모드 또는 모달 열기)
|
||||
const handleAddRow = useCallback(async () => {
|
||||
if (isModalMode) {
|
||||
setModalOpen(true);
|
||||
|
|
@ -794,11 +1087,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
const newRow: any = { _id: `new_${Date.now()}` };
|
||||
const currentRowCount = data.length;
|
||||
|
||||
// 먼저 동기적 자동 입력 값 적용
|
||||
// 동기적 자동 입력 값 적용
|
||||
for (const col of config.columns) {
|
||||
const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData);
|
||||
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
||||
// 채번 규칙: 즉시 API 호출
|
||||
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
||||
} else if (autoValue !== undefined) {
|
||||
newRow[col.key] = autoValue;
|
||||
|
|
@ -807,10 +1099,51 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// fromMainForm 등으로 넘어온 카테고리 코드 → 라벨 변환
|
||||
// allCategoryColumns에 해당하는 컬럼이거나 categoryLabelMap에 매핑이 있으면 변환
|
||||
const categoryColSet = new Set(allCategoryColumns);
|
||||
const unresolvedCodes: string[] = [];
|
||||
for (const col of config.columns) {
|
||||
const val = newRow[col.key];
|
||||
if (typeof val !== "string" || !val) continue;
|
||||
|
||||
// 이 컬럼이 카테고리 타입이거나, fromMainForm으로 가져온 값인 경우
|
||||
const isCategoryCol = categoryColSet.has(col.key);
|
||||
const isFromMainForm = col.autoFill?.type === "fromMainForm";
|
||||
|
||||
if (isCategoryCol || isFromMainForm) {
|
||||
if (categoryLabelMap[val]) {
|
||||
newRow[col.key] = categoryLabelMap[val];
|
||||
} else {
|
||||
unresolvedCodes.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolvedCodes.length > 0) {
|
||||
try {
|
||||
const resp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||
valueCodes: unresolvedCodes,
|
||||
});
|
||||
if (resp.data?.success && resp.data.data) {
|
||||
const labelData = resp.data.data as Record<string, string>;
|
||||
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||
for (const col of config.columns) {
|
||||
const val = newRow[col.key];
|
||||
if (typeof val === "string" && labelData[val]) {
|
||||
newRow[col.key] = labelData[val];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 변환 실패 시 코드 유지
|
||||
}
|
||||
}
|
||||
|
||||
const newData = [...data, newRow];
|
||||
handleDataChange(newData);
|
||||
}
|
||||
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData]);
|
||||
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData, categoryLabelMap, allCategoryColumns]);
|
||||
|
||||
// 모달에서 항목 선택 - 비동기로 변경
|
||||
const handleSelectItems = useCallback(
|
||||
|
|
@ -835,8 +1168,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
// 모든 컬럼 처리 (순서대로)
|
||||
for (const col of config.columns) {
|
||||
if (col.isSourceDisplay) {
|
||||
// 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용)
|
||||
row[`_display_${col.key}`] = item[col.key] || "";
|
||||
let displayVal = item[col.key] || "";
|
||||
// 카테고리 컬럼이면 코드→라벨 변환 (접두사 무관)
|
||||
if (typeof displayVal === "string" && categoryLabelMap[displayVal]) {
|
||||
displayVal = categoryLabelMap[displayVal];
|
||||
}
|
||||
row[`_display_${col.key}`] = displayVal;
|
||||
} else {
|
||||
// 자동 입력 값 적용
|
||||
const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData);
|
||||
|
|
@ -856,6 +1193,43 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
}),
|
||||
);
|
||||
|
||||
// 카테고리/fromMainForm 컬럼에서 미해결 코드 수집 및 변환
|
||||
const categoryColSet = new Set(allCategoryColumns);
|
||||
const unresolvedCodes = new Set<string>();
|
||||
for (const row of newRows) {
|
||||
for (const col of config.columns) {
|
||||
const val = row[col.key];
|
||||
if (typeof val !== "string" || !val) continue;
|
||||
const isCategoryCol = categoryColSet.has(col.key);
|
||||
const isFromMainForm = col.autoFill?.type === "fromMainForm";
|
||||
if ((isCategoryCol || isFromMainForm) && !categoryLabelMap[val]) {
|
||||
unresolvedCodes.add(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolvedCodes.size > 0) {
|
||||
try {
|
||||
const resp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||
valueCodes: Array.from(unresolvedCodes),
|
||||
});
|
||||
if (resp.data?.success && resp.data.data) {
|
||||
const labelData = resp.data.data as Record<string, string>;
|
||||
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
|
||||
for (const row of newRows) {
|
||||
for (const col of config.columns) {
|
||||
const val = row[col.key];
|
||||
if (typeof val === "string" && labelData[val]) {
|
||||
row[col.key] = labelData[val];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 변환 실패 시 코드 유지
|
||||
}
|
||||
}
|
||||
|
||||
const newData = [...data, ...newRows];
|
||||
handleDataChange(newData);
|
||||
setModalOpen(false);
|
||||
|
|
@ -869,6 +1243,8 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
generateAutoFillValueSync,
|
||||
generateNumberingCode,
|
||||
parentFormData,
|
||||
categoryLabelMap,
|
||||
allCategoryColumns,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -881,9 +1257,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
}, [config.columns]);
|
||||
|
||||
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeFormSave = async (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
|
|
@ -1112,7 +1485,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
equalizeWidthsTrigger={autoWidthTrigger}
|
||||
categoryColumns={sourceCategoryColumns}
|
||||
categoryColumns={allCategoryColumns}
|
||||
categoryLabelMap={categoryLabelMap}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -622,6 +622,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
config: configProp,
|
||||
value,
|
||||
onChange,
|
||||
onFormDataChange,
|
||||
tableName,
|
||||
columnName,
|
||||
isDesignMode, // 🔧 디자인 모드 (클릭 방지)
|
||||
|
|
@ -630,6 +631,9 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
// config가 없으면 기본값 사용
|
||||
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
|
||||
|
||||
// 엔티티 자동 채움: 같은 폼의 다른 컴포넌트 중 참조 테이블 컬럼을 자동 감지
|
||||
const allComponents = (props as any).allComponents as any[] | undefined;
|
||||
|
||||
const [options, setOptions] = useState<SelectOption[]>(config.options || []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [optionsLoaded, setOptionsLoaded] = useState(false);
|
||||
|
|
@ -742,10 +746,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
const valueCol = entityValueColumn || "id";
|
||||
const labelCol = entityLabelColumn || "name";
|
||||
const response = await apiClient.get(`/entity/${entityTable}/options`, {
|
||||
params: {
|
||||
value: valueCol,
|
||||
label: labelCol,
|
||||
},
|
||||
params: { value: valueCol, label: labelCol },
|
||||
});
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
|
|
@ -819,6 +820,70 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
loadOptions();
|
||||
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
|
||||
|
||||
// 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지
|
||||
const autoFillTargets = useMemo(() => {
|
||||
if (source !== "entity" || !entityTable || !allComponents) return [];
|
||||
|
||||
const targets: Array<{ sourceField: string; targetColumnName: string }> = [];
|
||||
for (const comp of allComponents) {
|
||||
if (comp.id === id) continue;
|
||||
|
||||
// overrides 구조 지원 (DB에서 로드 시 overrides 안에 데이터가 있음)
|
||||
const ov = (comp as any).overrides || {};
|
||||
const compColumnName = comp.columnName || ov.columnName || comp.componentConfig?.columnName || "";
|
||||
|
||||
// 방법1: entityJoinTable 속성이 있는 경우
|
||||
const joinTable = comp.entityJoinTable || ov.entityJoinTable || comp.componentConfig?.entityJoinTable;
|
||||
const joinColumn = comp.entityJoinColumn || ov.entityJoinColumn || comp.componentConfig?.entityJoinColumn;
|
||||
if (joinTable === entityTable && joinColumn) {
|
||||
targets.push({ sourceField: joinColumn, targetColumnName: compColumnName });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 방법2: columnName이 "테이블명.컬럼명" 형식인 경우 (예: item_info.unit)
|
||||
if (compColumnName.includes(".")) {
|
||||
const [prefix, actualColumn] = compColumnName.split(".");
|
||||
if (prefix === entityTable && actualColumn) {
|
||||
targets.push({ sourceField: actualColumn, targetColumnName: compColumnName });
|
||||
}
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}, [source, entityTable, allComponents, id]);
|
||||
|
||||
// 엔티티 autoFill 적용 래퍼
|
||||
const handleChangeWithAutoFill = useCallback((newValue: string | string[]) => {
|
||||
onChange?.(newValue);
|
||||
|
||||
if (autoFillTargets.length === 0 || !onFormDataChange || !entityTable) return;
|
||||
|
||||
const selectedKey = typeof newValue === "string" ? newValue : newValue[0];
|
||||
if (!selectedKey) return;
|
||||
|
||||
const valueCol = entityValueColumn || "id";
|
||||
|
||||
apiClient.get(`/table-management/tables/${entityTable}/data-with-joins`, {
|
||||
params: {
|
||||
page: 1,
|
||||
size: 1,
|
||||
search: JSON.stringify({ [valueCol]: selectedKey }),
|
||||
autoFilter: JSON.stringify({ enabled: true, filterColumn: "company_code", userField: "companyCode" }),
|
||||
},
|
||||
}).then((res) => {
|
||||
const responseData = res.data?.data;
|
||||
const rows = responseData?.data || responseData?.rows || [];
|
||||
if (rows.length > 0) {
|
||||
const fullData = rows[0];
|
||||
for (const target of autoFillTargets) {
|
||||
const sourceValue = fullData[target.sourceField];
|
||||
if (sourceValue !== undefined) {
|
||||
onFormDataChange(target.targetColumnName, sourceValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch((err) => console.error("autoFill 조회 실패:", err));
|
||||
}, [onChange, autoFillTargets, onFormDataChange, entityTable, entityValueColumn]);
|
||||
|
||||
// 모드별 컴포넌트 렌더링
|
||||
const renderSelect = () => {
|
||||
if (loading) {
|
||||
|
|
@ -876,12 +941,12 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
|
||||
switch (config.mode) {
|
||||
case "dropdown":
|
||||
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운
|
||||
case "combobox":
|
||||
return (
|
||||
<DropdownSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
placeholder="선택"
|
||||
searchable={config.mode === "combobox" ? true : config.searchable}
|
||||
multiple={config.multiple}
|
||||
|
|
@ -897,18 +962,18 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
<RadioSelect
|
||||
options={options}
|
||||
value={typeof value === "string" ? value : value?.[0]}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
onChange={(v) => handleChangeWithAutoFill(v)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case "check":
|
||||
case "checkbox": // 🔧 기존 저장된 값 호환
|
||||
case "checkbox":
|
||||
return (
|
||||
<CheckSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
onChange={onChange}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
|
@ -919,7 +984,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
<TagSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
onChange={onChange}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
|
@ -930,7 +995,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
<TagboxSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
onChange={onChange}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
placeholder={config.placeholder || "선택하세요"}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
|
|
@ -943,7 +1008,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
<ToggleSelect
|
||||
options={options}
|
||||
value={typeof value === "string" ? value : value?.[0]}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
onChange={(v) => handleChangeWithAutoFill(v)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
|
|
@ -953,7 +1018,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
<SwapSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
onChange={onChange}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
|
@ -964,7 +1029,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
<DropdownSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
disabled={isDisabled}
|
||||
style={heightStyle}
|
||||
/>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1214,13 +1214,21 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
|
||||
)}
|
||||
|
||||
{/* 편집 가능 체크박스 */}
|
||||
{/* 편집 가능 토글 */}
|
||||
{!col.isSourceDisplay && (
|
||||
<Checkbox
|
||||
checked={col.editable ?? true}
|
||||
onCheckedChange={(checked) => updateColumnProp(col.key, "editable", !!checked)}
|
||||
title="편집 가능"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))}
|
||||
className={cn(
|
||||
"shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors",
|
||||
(col.editable ?? true)
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
|
||||
)}
|
||||
title={(col.editable ?? true) ? "편집 가능 (클릭하여 읽기 전용으로 변경)" : "읽기 전용 (클릭하여 편집 가능으로 변경)"}
|
||||
>
|
||||
{(col.editable ?? true) ? "편집" : "읽기"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -20,41 +20,111 @@ interface ColumnOption {
|
|||
columnLabel: string;
|
||||
}
|
||||
|
||||
interface CategoryValueOption {
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
}
|
||||
|
||||
interface V2SelectConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
/** 컬럼의 inputType (entity 타입인 경우에만 엔티티 소스 표시) */
|
||||
/** 컬럼의 inputType (entity/category 타입 확인용) */
|
||||
inputType?: string;
|
||||
/** 현재 테이블명 (카테고리 값 조회용) */
|
||||
tableName?: string;
|
||||
/** 현재 컬럼명 (카테고리 값 조회용) */
|
||||
columnName?: string;
|
||||
}
|
||||
|
||||
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config, onChange, inputType }) => {
|
||||
// 엔티티 타입인지 확인
|
||||
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
inputType,
|
||||
tableName,
|
||||
columnName,
|
||||
}) => {
|
||||
const isEntityType = inputType === "entity";
|
||||
// 엔티티 테이블의 컬럼 목록
|
||||
const isCategoryType = inputType === "category";
|
||||
|
||||
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
// 카테고리 값 목록
|
||||
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
|
||||
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
||||
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
// 카테고리 타입이면 source를 자동으로 category로 설정
|
||||
useEffect(() => {
|
||||
if (isCategoryType && config.source !== "category") {
|
||||
onChange({ ...config, source: "category" });
|
||||
}
|
||||
}, [isCategoryType]);
|
||||
|
||||
// 카테고리 값 로드
|
||||
const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => {
|
||||
if (!catTable || !catColumn) {
|
||||
setCategoryValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingCategoryValues(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
const flattenTree = (items: any[], depth: number = 0): CategoryValueOption[] => {
|
||||
const result: CategoryValueOption[] = [];
|
||||
for (const item of items) {
|
||||
result.push({
|
||||
valueCode: item.valueCode,
|
||||
valueLabel: depth > 0 ? `${" ".repeat(depth)}${item.valueLabel}` : item.valueLabel,
|
||||
});
|
||||
if (item.children && item.children.length > 0) {
|
||||
result.push(...flattenTree(item.children, depth + 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
setCategoryValues(flattenTree(data.data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 값 조회 실패:", error);
|
||||
setCategoryValues([]);
|
||||
} finally {
|
||||
setLoadingCategoryValues(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 카테고리 소스일 때 값 로드
|
||||
useEffect(() => {
|
||||
if (config.source === "category") {
|
||||
const catTable = config.categoryTable || tableName;
|
||||
const catColumn = config.categoryColumn || columnName;
|
||||
if (catTable && catColumn) {
|
||||
loadCategoryValues(catTable, catColumn);
|
||||
}
|
||||
}
|
||||
}, [config.source, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]);
|
||||
|
||||
// 엔티티 테이블 변경 시 컬럼 목록 조회
|
||||
const loadEntityColumns = useCallback(async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
const loadEntityColumns = useCallback(async (tblName: string) => {
|
||||
if (!tblName) {
|
||||
setEntityColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=500`);
|
||||
const response = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`);
|
||||
const data = response.data.data || response.data;
|
||||
const columns = data.columns || data || [];
|
||||
|
||||
const columnOptions: ColumnOption[] = columns.map((col: any) => {
|
||||
const name = col.columnName || col.column_name || col.name;
|
||||
// displayName 우선 사용
|
||||
const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name;
|
||||
|
||||
return {
|
||||
|
|
@ -72,7 +142,6 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 엔티티 테이블이 변경되면 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (config.source === "entity" && config.entityTable) {
|
||||
loadEntityColumns(config.entityTable);
|
||||
|
|
@ -98,6 +167,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
|||
updateConfig("options", newOptions);
|
||||
};
|
||||
|
||||
// 현재 source 결정 (카테고리 타입이면 강제 category)
|
||||
const effectiveSource = isCategoryType ? "category" : config.source || "static";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 선택 모드 */}
|
||||
|
|
@ -125,21 +197,102 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
|||
{/* 데이터 소스 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">데이터 소스</Label>
|
||||
<Select value={config.source || "static"} onValueChange={(value) => updateConfig("source", value)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="소스 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">정적 옵션</SelectItem>
|
||||
<SelectItem value="code">공통 코드</SelectItem>
|
||||
{/* 엔티티 타입일 때만 엔티티 옵션 표시 */}
|
||||
{isEntityType && <SelectItem value="entity">엔티티</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isCategoryType ? (
|
||||
<div className="bg-muted flex h-8 items-center rounded-md px-3">
|
||||
<span className="text-xs font-medium text-emerald-600">카테고리 (자동 설정)</span>
|
||||
</div>
|
||||
) : (
|
||||
<Select value={config.source || "static"} onValueChange={(value) => updateConfig("source", value)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="소스 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">정적 옵션</SelectItem>
|
||||
<SelectItem value="code">공통 코드</SelectItem>
|
||||
<SelectItem value="category">카테고리</SelectItem>
|
||||
{isEntityType && <SelectItem value="entity">엔티티</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 카테고리 설정 */}
|
||||
{effectiveSource === "category" && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">카테고리 정보</Label>
|
||||
<div className="bg-muted rounded-md p-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-[10px]">테이블</p>
|
||||
<p className="text-xs font-medium">{config.categoryTable || tableName || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-[10px]">컬럼</p>
|
||||
<p className="text-xs font-medium">{config.categoryColumn || columnName || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 값 로딩 중 */}
|
||||
{loadingCategoryValues && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
카테고리 값 로딩 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 값 목록 표시 */}
|
||||
{categoryValues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">카테고리 값 ({categoryValues.length}개)</Label>
|
||||
<div className="bg-muted max-h-32 space-y-0.5 overflow-y-auto rounded-md p-1.5">
|
||||
{categoryValues.map((cv) => (
|
||||
<div key={cv.valueCode} className="flex items-center gap-2 px-1.5 py-0.5">
|
||||
<span className="text-muted-foreground shrink-0 font-mono text-[10px]">{cv.valueCode}</span>
|
||||
<span className="truncate text-xs">{cv.valueLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기본값 설정 */}
|
||||
{categoryValues.length > 0 && (
|
||||
<div className="border-t pt-2">
|
||||
<Label className="text-xs font-medium">기본값</Label>
|
||||
<Select
|
||||
value={config.defaultValue || "_none_"}
|
||||
onValueChange={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="기본값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none_">선택 안함</SelectItem>
|
||||
{categoryValues.map((cv) => (
|
||||
<SelectItem key={cv.valueCode} value={cv.valueCode}>
|
||||
{cv.valueLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">화면 로드 시 자동 선택될 카테고리 값</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 값 없음 안내 */}
|
||||
{!loadingCategoryValues && categoryValues.length === 0 && (
|
||||
<p className="text-[10px] text-amber-600">
|
||||
카테고리 값이 없습니다. 테이블 카테고리 관리에서 값을 추가해주세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 정적 옵션 관리 */}
|
||||
{(config.source || "static") === "static" && (
|
||||
{effectiveSource === "static" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">옵션 목록</Label>
|
||||
|
|
@ -199,8 +352,8 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 공통 코드 설정 - 테이블 타입 관리에서 설정되므로 정보만 표시 */}
|
||||
{config.source === "code" && (
|
||||
{/* 공통 코드 설정 */}
|
||||
{effectiveSource === "code" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">코드 그룹</Label>
|
||||
{config.codeGroup ? (
|
||||
|
|
@ -212,7 +365,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
|||
)}
|
||||
|
||||
{/* 엔티티(참조 테이블) 설정 */}
|
||||
{config.source === "entity" && (
|
||||
{effectiveSource === "entity" && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">참조 테이블</Label>
|
||||
|
|
@ -228,7 +381,6 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 로딩 중 표시 */}
|
||||
{loadingColumns && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
|
|
@ -236,7 +388,6 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 선택 - 테이블이 설정되어 있고 컬럼 목록이 있는 경우 Select로 표시 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">값 컬럼 (코드)</Label>
|
||||
|
|
@ -296,12 +447,20 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼이 없는 경우 안내 */}
|
||||
{config.entityTable && !loadingColumns && entityColumns.length === 0 && (
|
||||
<p className="text-[10px] text-amber-600">
|
||||
테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{config.entityTable && entityColumns.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
같은 폼에 참조 테이블({config.entityTable})의 컬럼이 배치되어 있으면, 엔티티 선택 시 해당 필드가 자동으로
|
||||
채워집니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,17 +6,28 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
||||
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
||||
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
||||
import { logger } from "@/lib/utils/logger";
|
||||
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
|
||||
/**
|
||||
* 대기 중인 데이터 전달 항목
|
||||
* 타겟 컴포넌트가 아직 마운트되지 않은 경우 (조건부 레이어 등) 버퍼에 저장
|
||||
*/
|
||||
export interface PendingTransfer {
|
||||
targetComponentId: string;
|
||||
data: any[];
|
||||
config: DataReceiverConfig;
|
||||
timestamp: number;
|
||||
targetLayerId?: string;
|
||||
}
|
||||
|
||||
interface ScreenContextValue {
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
menuObjid?: number; // 메뉴 OBJID (카테고리 값 조회 시 필요)
|
||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
||||
menuObjid?: number;
|
||||
splitPanelPosition?: SplitPanelPosition;
|
||||
|
||||
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||
formData: Record<string, any>;
|
||||
updateFormData: (fieldName: string, value: any) => void;
|
||||
|
||||
|
|
@ -33,6 +44,11 @@ interface ScreenContextValue {
|
|||
// 모든 컴포넌트 조회
|
||||
getAllDataProviders: () => Map<string, DataProvidable>;
|
||||
getAllDataReceivers: () => Map<string, DataReceivable>;
|
||||
|
||||
// 대기 중인 데이터 전달 (레이어 내부 컴포넌트 미마운트 대응)
|
||||
addPendingTransfer: (transfer: PendingTransfer) => void;
|
||||
getPendingTransfer: (componentId: string) => PendingTransfer | undefined;
|
||||
clearPendingTransfer: (componentId: string) => void;
|
||||
}
|
||||
|
||||
const ScreenContext = createContext<ScreenContextValue | null>(null);
|
||||
|
|
@ -57,11 +73,10 @@ export function ScreenContextProvider({
|
|||
}: ScreenContextProviderProps) {
|
||||
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
||||
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
||||
const pendingTransfersRef = useRef<Map<string, PendingTransfer>>(new Map());
|
||||
|
||||
// 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 🆕 폼 데이터 업데이트 함수
|
||||
const updateFormData = useCallback((fieldName: string, value: any) => {
|
||||
setFormData((prev) => {
|
||||
const updated = { ...prev, [fieldName]: value };
|
||||
|
|
@ -87,6 +102,25 @@ export function ScreenContextProvider({
|
|||
const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => {
|
||||
dataReceiversRef.current.set(componentId, receiver);
|
||||
logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType });
|
||||
|
||||
// 대기 중인 데이터 전달이 있으면 즉시 수신 처리
|
||||
const pending = pendingTransfersRef.current.get(componentId);
|
||||
if (pending) {
|
||||
logger.info("대기 중인 데이터 전달 자동 수신", {
|
||||
componentId,
|
||||
dataCount: pending.data.length,
|
||||
waitedMs: Date.now() - pending.timestamp,
|
||||
});
|
||||
receiver
|
||||
.receiveData(pending.data, pending.config)
|
||||
.then(() => {
|
||||
pendingTransfersRef.current.delete(componentId);
|
||||
logger.info("대기 데이터 전달 완료", { componentId });
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("대기 데이터 전달 실패", { componentId, error: err });
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const unregisterDataReceiver = useCallback((componentId: string) => {
|
||||
|
|
@ -110,7 +144,24 @@ export function ScreenContextProvider({
|
|||
return new Map(dataReceiversRef.current);
|
||||
}, []);
|
||||
|
||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||
const addPendingTransfer = useCallback((transfer: PendingTransfer) => {
|
||||
pendingTransfersRef.current.set(transfer.targetComponentId, transfer);
|
||||
logger.info("데이터 전달 대기열 추가", {
|
||||
targetComponentId: transfer.targetComponentId,
|
||||
dataCount: transfer.data.length,
|
||||
targetLayerId: transfer.targetLayerId,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getPendingTransfer = useCallback((componentId: string) => {
|
||||
return pendingTransfersRef.current.get(componentId);
|
||||
}, []);
|
||||
|
||||
const clearPendingTransfer = useCallback((componentId: string) => {
|
||||
pendingTransfersRef.current.delete(componentId);
|
||||
logger.debug("대기 데이터 전달 클리어", { componentId });
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo<ScreenContextValue>(
|
||||
() => ({
|
||||
screenId,
|
||||
|
|
@ -127,6 +178,9 @@ export function ScreenContextProvider({
|
|||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
addPendingTransfer,
|
||||
getPendingTransfer,
|
||||
clearPendingTransfer,
|
||||
}),
|
||||
[
|
||||
screenId,
|
||||
|
|
@ -143,6 +197,9 @@ export function ScreenContextProvider({
|
|||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
addPendingTransfer,
|
||||
getPendingTransfer,
|
||||
clearPendingTransfer,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiCall } from "@/lib/api/client";
|
||||
import { AuthLogger } from "@/lib/authLogger";
|
||||
|
||||
interface UserInfo {
|
||||
userId: string;
|
||||
|
|
@ -161,13 +162,15 @@ export const useAuth = () => {
|
|||
|
||||
const token = TokenManager.getToken();
|
||||
if (!token || TokenManager.isTokenExpired(token)) {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`);
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰이 유효하면 우선 인증된 상태로 설정
|
||||
AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작");
|
||||
|
||||
setAuthStatus({
|
||||
isLoggedIn: true,
|
||||
isAdmin: false,
|
||||
|
|
@ -186,15 +189,16 @@ export const useAuth = () => {
|
|||
};
|
||||
|
||||
setAuthStatus(finalAuthStatus);
|
||||
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`);
|
||||
|
||||
// API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리)
|
||||
if (!finalAuthStatus.isLoggedIn) {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거");
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
}
|
||||
} else {
|
||||
// userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도");
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const tempUser: UserInfo = {
|
||||
|
|
@ -210,14 +214,14 @@ export const useAuth = () => {
|
|||
isAdmin: tempUser.isAdmin,
|
||||
});
|
||||
} catch {
|
||||
// 토큰 파싱도 실패하면 비인증 상태로 전환
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "토큰 파싱 실패 → 비인증 전환");
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도");
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const tempUser: UserInfo = {
|
||||
|
|
@ -233,6 +237,7 @@ export const useAuth = () => {
|
|||
isAdmin: tempUser.isAdmin,
|
||||
});
|
||||
} catch {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "최종 fallback 실패 → 비인증 전환");
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
|
|
@ -408,19 +413,19 @@ export const useAuth = () => {
|
|||
const token = TokenManager.getToken();
|
||||
|
||||
if (token && !TokenManager.isTokenExpired(token)) {
|
||||
// 유효한 토큰 → 우선 인증 상태로 설정 후 API 확인
|
||||
AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`);
|
||||
setAuthStatus({
|
||||
isLoggedIn: true,
|
||||
isAdmin: false,
|
||||
});
|
||||
refreshUserData();
|
||||
} else if (token && TokenManager.isTokenExpired(token)) {
|
||||
// 만료된 토큰 → 정리 (리다이렉트는 AuthGuard에서 처리)
|
||||
AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`);
|
||||
TokenManager.removeToken();
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setLoading(false);
|
||||
} else {
|
||||
// 토큰 없음 → 비인증 상태 (리다이렉트는 AuthGuard에서 처리)
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { MenuItem, MenuState } from "@/types/menu";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { AuthLogger } from "@/lib/authLogger";
|
||||
|
||||
/**
|
||||
* 메뉴 관련 비즈니스 로직을 관리하는 커스텀 훅
|
||||
|
|
@ -84,8 +85,8 @@ export const useMenu = (user: any, authLoading: boolean) => {
|
|||
} else {
|
||||
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
|
||||
}
|
||||
} catch {
|
||||
// API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리)
|
||||
} catch (err: any) {
|
||||
AuthLogger.log("MENU_LOAD_FAIL", `메뉴 로드 실패: ${err?.response?.status || err?.message || "unknown"}`);
|
||||
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
|
||||
}
|
||||
}, [convertToUpperCaseKeys, buildMenuTree]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
import { AuthLogger } from "@/lib/authLogger";
|
||||
|
||||
const authLog = (event: string, detail: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
AuthLogger.log(event as any, detail);
|
||||
} catch {
|
||||
// 로거 실패해도 앱 동작에 영향 없음
|
||||
}
|
||||
};
|
||||
|
||||
// API URL 동적 설정 - 환경변수 우선 사용
|
||||
const getApiBaseUrl = (): string => {
|
||||
|
|
@ -149,9 +159,12 @@ const refreshToken = async (): Promise<string | null> => {
|
|||
try {
|
||||
const currentToken = TokenManager.getToken();
|
||||
if (!currentToken) {
|
||||
authLog("TOKEN_REFRESH_FAIL", "갱신 시도했으나 토큰 자체가 없음");
|
||||
return null;
|
||||
}
|
||||
|
||||
authLog("TOKEN_REFRESH_START", `남은시간: ${Math.round(TokenManager.getTimeUntilExpiry(currentToken) / 60000)}분`);
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/auth/refresh`,
|
||||
{},
|
||||
|
|
@ -165,10 +178,13 @@ const refreshToken = async (): Promise<string | null> => {
|
|||
if (response.data?.success && response.data?.data?.token) {
|
||||
const newToken = response.data.data.token;
|
||||
TokenManager.setToken(newToken);
|
||||
authLog("TOKEN_REFRESH_SUCCESS", "토큰 갱신 완료");
|
||||
return newToken;
|
||||
}
|
||||
authLog("TOKEN_REFRESH_FAIL", `API 응답 실패: success=${response.data?.success}`);
|
||||
return null;
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
authLog("TOKEN_REFRESH_FAIL", `API 호출 에러: ${err?.response?.status || err?.message || "unknown"}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -210,16 +226,21 @@ const setupVisibilityRefresh = (): void => {
|
|||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
const token = TokenManager.getToken();
|
||||
if (!token) return;
|
||||
if (!token) {
|
||||
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TokenManager.isTokenExpired(token)) {
|
||||
// 만료됐으면 갱신 시도
|
||||
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 감지 → 갱신 시도");
|
||||
refreshToken().then((newToken) => {
|
||||
if (!newToken) {
|
||||
authLog("REDIRECT_TO_LOGIN", "탭 복귀 후 토큰 갱신 실패로 리다이렉트");
|
||||
redirectToLogin();
|
||||
}
|
||||
});
|
||||
} else if (TokenManager.isTokenExpiringSoon(token)) {
|
||||
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 임박 → 갱신 시도");
|
||||
refreshToken();
|
||||
}
|
||||
}
|
||||
|
|
@ -268,6 +289,7 @@ const redirectToLogin = (): void => {
|
|||
if (isRedirecting) return;
|
||||
if (window.location.pathname === "/login") return;
|
||||
|
||||
authLog("REDIRECT_TO_LOGIN", `리다이렉트 실행 (from: ${window.location.pathname})`);
|
||||
isRedirecting = true;
|
||||
TokenManager.removeToken();
|
||||
window.location.href = "/login";
|
||||
|
|
@ -301,15 +323,13 @@ apiClient.interceptors.request.use(
|
|||
|
||||
if (token) {
|
||||
if (!TokenManager.isTokenExpired(token)) {
|
||||
// 유효한 토큰 → 그대로 사용
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
// 만료된 토큰 → 갱신 시도 후 사용
|
||||
authLog("TOKEN_EXPIRED_DETECTED", `요청 전 토큰 만료 감지 (${config.url}) → 갱신 시도`);
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
// 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -378,12 +398,16 @@ apiClient.interceptors.response.use(
|
|||
|
||||
// 401 에러 처리 (핵심 개선)
|
||||
if (status === 401 && typeof window !== "undefined") {
|
||||
const errorData = error.response?.data as { error?: { code?: string } };
|
||||
const errorData = error.response?.data as { error?: { code?: string; details?: string } };
|
||||
const errorCode = errorData?.error?.code;
|
||||
const errorDetails = errorData?.error?.details;
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
authLog("API_401_RECEIVED", `URL: ${url} | 코드: ${errorCode || "없음"} | 상세: ${errorDetails || "없음"}`);
|
||||
|
||||
// 이미 재시도한 요청이면 로그인으로
|
||||
if (originalRequest?._retry) {
|
||||
authLog("REDIRECT_TO_LOGIN", `재시도 후에도 401 (${url}) → 로그인 리다이렉트`);
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
|
@ -395,6 +419,7 @@ apiClient.interceptors.response.use(
|
|||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
authLog("API_401_RETRY", `토큰 만료로 갱신 후 재시도 (${url})`);
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
isRefreshing = false;
|
||||
|
|
@ -404,17 +429,18 @@ apiClient.interceptors.response.use(
|
|||
} else {
|
||||
isRefreshing = false;
|
||||
onRefreshFailed(new Error("토큰 갱신 실패"));
|
||||
authLog("REDIRECT_TO_LOGIN", `토큰 갱신 실패 (${url}) → 로그인 리다이렉트`);
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
isRefreshing = false;
|
||||
onRefreshFailed(refreshError as Error);
|
||||
authLog("REDIRECT_TO_LOGIN", `토큰 갱신 예외 (${url}) → 로그인 리다이렉트`);
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
} else {
|
||||
// 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도
|
||||
try {
|
||||
const newToken = await waitForTokenRefresh();
|
||||
originalRequest._retry = true;
|
||||
|
|
@ -427,6 +453,7 @@ apiClient.interceptors.response.use(
|
|||
}
|
||||
|
||||
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
|
||||
authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`);
|
||||
redirectToLogin();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* 인증 이벤트 로거
|
||||
* - 토큰 갱신/삭제/리다이렉트 발생 시 원인을 기록
|
||||
* - localStorage에 저장하여 브라우저에서 확인 가능
|
||||
* - 콘솔에서 window.__AUTH_LOG.show() 로 조회
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "auth_debug_log";
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
export type AuthEventType =
|
||||
| "TOKEN_SET"
|
||||
| "TOKEN_REMOVED"
|
||||
| "TOKEN_EXPIRED_DETECTED"
|
||||
| "TOKEN_REFRESH_START"
|
||||
| "TOKEN_REFRESH_SUCCESS"
|
||||
| "TOKEN_REFRESH_FAIL"
|
||||
| "REDIRECT_TO_LOGIN"
|
||||
| "API_401_RECEIVED"
|
||||
| "API_401_RETRY"
|
||||
| "AUTH_CHECK_START"
|
||||
| "AUTH_CHECK_SUCCESS"
|
||||
| "AUTH_CHECK_FAIL"
|
||||
| "AUTH_GUARD_BLOCK"
|
||||
| "AUTH_GUARD_PASS"
|
||||
| "MENU_LOAD_FAIL"
|
||||
| "VISIBILITY_CHANGE"
|
||||
| "MIDDLEWARE_REDIRECT";
|
||||
|
||||
interface AuthLogEntry {
|
||||
timestamp: string;
|
||||
event: AuthEventType;
|
||||
detail: string;
|
||||
tokenStatus: string;
|
||||
url: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
function getTokenSummary(): string {
|
||||
if (typeof window === "undefined") return "SSR";
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
if (!token) return "없음";
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const exp = payload.exp * 1000;
|
||||
const now = Date.now();
|
||||
const remainMs = exp - now;
|
||||
|
||||
if (remainMs <= 0) {
|
||||
return `만료됨(${Math.abs(Math.round(remainMs / 60000))}분 전)`;
|
||||
}
|
||||
|
||||
const remainMin = Math.round(remainMs / 60000);
|
||||
const remainHour = Math.floor(remainMin / 60);
|
||||
const min = remainMin % 60;
|
||||
|
||||
return `유효(${remainHour}h${min}m 남음, user:${payload.userId})`;
|
||||
} catch {
|
||||
return "파싱실패";
|
||||
}
|
||||
}
|
||||
|
||||
function getCallStack(): string {
|
||||
try {
|
||||
const stack = new Error().stack || "";
|
||||
const lines = stack.split("\n").slice(3, 7);
|
||||
return lines.map((l) => l.trim()).join(" <- ");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeLog(event: AuthEventType, detail: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const entry: AuthLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
event,
|
||||
detail,
|
||||
tokenStatus: getTokenSummary(),
|
||||
url: window.location.pathname + window.location.search,
|
||||
stack: getCallStack(),
|
||||
};
|
||||
|
||||
// 콘솔 출력 (그룹)
|
||||
const isError = ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK"].includes(event);
|
||||
const logFn = isError ? console.warn : console.debug;
|
||||
logFn(`[AuthLog] ${event}: ${detail} | 토큰: ${entry.tokenStatus} | ${entry.url}`);
|
||||
|
||||
// localStorage에 저장
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const logs: AuthLogEntry[] = stored ? JSON.parse(stored) : [];
|
||||
logs.push(entry);
|
||||
|
||||
// 최대 개수 초과 시 오래된 것 제거
|
||||
while (logs.length > MAX_ENTRIES) {
|
||||
logs.shift();
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(logs));
|
||||
} catch {
|
||||
// localStorage 공간 부족 등의 경우 무시
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 로그 조회
|
||||
*/
|
||||
function getLogs(): AuthLogEntry[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 초기화
|
||||
*/
|
||||
function clearLogs() {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그를 테이블 형태로 콘솔에 출력
|
||||
*/
|
||||
function showLogs(filter?: AuthEventType | "ERROR") {
|
||||
const logs = getLogs();
|
||||
|
||||
if (logs.length === 0) {
|
||||
console.log("[AuthLog] 저장된 로그가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
let filtered = logs;
|
||||
if (filter === "ERROR") {
|
||||
filtered = logs.filter((l) =>
|
||||
["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK", "AUTH_CHECK_FAIL", "TOKEN_EXPIRED_DETECTED"].includes(l.event)
|
||||
);
|
||||
} else if (filter) {
|
||||
filtered = logs.filter((l) => l.event === filter);
|
||||
}
|
||||
|
||||
console.log(`\n[AuthLog] 총 ${filtered.length}건 (전체 ${logs.length}건)`);
|
||||
console.log("─".repeat(120));
|
||||
|
||||
filtered.forEach((entry, i) => {
|
||||
const time = entry.timestamp.replace("T", " ").split(".")[0];
|
||||
console.log(
|
||||
`${i + 1}. [${time}] ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}\n`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 리다이렉트 원인 조회
|
||||
*/
|
||||
function getLastRedirectReason(): AuthLogEntry | null {
|
||||
const logs = getLogs();
|
||||
for (let i = logs.length - 1; i >= 0; i--) {
|
||||
if (logs[i].event === "REDIRECT_TO_LOGIN") {
|
||||
return logs[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그를 텍스트 파일로 다운로드
|
||||
*/
|
||||
function downloadLogs() {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const logs = getLogs();
|
||||
if (logs.length === 0) {
|
||||
console.log("[AuthLog] 저장된 로그가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const text = logs
|
||||
.map((entry, i) => {
|
||||
const time = entry.timestamp.replace("T", " ").split(".")[0];
|
||||
return `[${i + 1}] ${time} | ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `auth-debug-log_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-")}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log("[AuthLog] 로그 파일 다운로드 완료");
|
||||
}
|
||||
|
||||
// 전역 접근 가능하게 등록
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).__AUTH_LOG = {
|
||||
show: showLogs,
|
||||
errors: () => showLogs("ERROR"),
|
||||
clear: clearLogs,
|
||||
download: downloadLogs,
|
||||
lastRedirect: getLastRedirectReason,
|
||||
raw: getLogs,
|
||||
};
|
||||
}
|
||||
|
||||
export const AuthLogger = {
|
||||
log: writeLog,
|
||||
getLogs,
|
||||
clearLogs,
|
||||
showLogs,
|
||||
downloadLogs,
|
||||
getLastRedirectReason,
|
||||
};
|
||||
|
||||
export default AuthLogger;
|
||||
|
|
@ -8,6 +8,76 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
|||
|
||||
// 통합 폼 시스템 import
|
||||
import { useV2FormOptional } from "@/components/v2/V2FormContext";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
|
||||
const columnMetaCache: Record<string, Record<string, any>> = {};
|
||||
const columnMetaLoading: Record<string, Promise<void>> = {};
|
||||
|
||||
async function loadColumnMeta(tableName: string): Promise<void> {
|
||||
if (columnMetaCache[tableName] || columnMetaLoading[tableName]) return;
|
||||
|
||||
columnMetaLoading[tableName] = (async () => {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`);
|
||||
const data = response.data.data || response.data;
|
||||
const columns = data.columns || data || [];
|
||||
const map: Record<string, any> = {};
|
||||
for (const col of columns) {
|
||||
const name = col.column_name || col.columnName;
|
||||
if (name) map[name] = col;
|
||||
}
|
||||
columnMetaCache[tableName] = map;
|
||||
} catch {
|
||||
columnMetaCache[tableName] = {};
|
||||
} finally {
|
||||
delete columnMetaLoading[tableName];
|
||||
}
|
||||
})();
|
||||
|
||||
await columnMetaLoading[tableName];
|
||||
}
|
||||
|
||||
// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완)
|
||||
function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any {
|
||||
if (!tableName || !columnName) return componentConfig;
|
||||
|
||||
const meta = columnMetaCache[tableName]?.[columnName];
|
||||
if (!meta) return componentConfig;
|
||||
|
||||
const inputType = meta.input_type || meta.inputType;
|
||||
if (!inputType) return componentConfig;
|
||||
|
||||
// 이미 source가 올바르게 설정된 경우 건드리지 않음
|
||||
const existingSource = componentConfig?.source;
|
||||
if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") {
|
||||
return componentConfig;
|
||||
}
|
||||
|
||||
const merged = { ...componentConfig };
|
||||
|
||||
// source가 미설정/기본값일 때만 DB 메타데이터로 보완
|
||||
if (inputType === "entity") {
|
||||
const refTable = meta.reference_table || meta.referenceTable;
|
||||
const refColumn = meta.reference_column || meta.referenceColumn;
|
||||
const displayCol = meta.display_column || meta.displayColumn;
|
||||
if (refTable && !merged.entityTable) {
|
||||
merged.source = "entity";
|
||||
merged.entityTable = refTable;
|
||||
merged.entityValueColumn = refColumn || "id";
|
||||
merged.entityLabelColumn = displayCol || "name";
|
||||
}
|
||||
} else if (inputType === "category" && !existingSource) {
|
||||
merged.source = "category";
|
||||
} else if (inputType === "select" && !existingSource) {
|
||||
const detail = typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : (meta.detail_settings || {});
|
||||
if (detail.options && !merged.options?.length) {
|
||||
merged.options = detail.options;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// 컴포넌트 렌더러 인터페이스
|
||||
export interface ComponentRenderer {
|
||||
|
|
@ -175,6 +245,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
children,
|
||||
...props
|
||||
}) => {
|
||||
// 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드)
|
||||
const screenTableName = props.tableName || (component as any).tableName;
|
||||
const [, forceUpdate] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
if (screenTableName && !columnMetaCache[screenTableName]) {
|
||||
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
|
||||
}
|
||||
}, [screenTableName]);
|
||||
|
||||
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
||||
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
|
||||
const extractTypeFromUrl = (url: string | undefined): string | undefined => {
|
||||
|
|
@ -551,24 +630,34 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
height: finalStyle.height,
|
||||
};
|
||||
|
||||
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
|
||||
const isEntityJoinColumn = fieldName?.includes(".");
|
||||
const baseColumnName = isEntityJoinColumn ? undefined : fieldName;
|
||||
const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
|
||||
|
||||
// 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제
|
||||
const effectiveComponent = isEntityJoinColumn
|
||||
? { ...component, componentConfig: mergedComponentConfig, readonly: false }
|
||||
: { ...component, componentConfig: mergedComponentConfig };
|
||||
|
||||
const rendererProps = {
|
||||
component,
|
||||
component: effectiveComponent,
|
||||
isSelected,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
size: component.size || newComponent.defaultSize,
|
||||
position: component.position,
|
||||
config: component.componentConfig,
|
||||
componentConfig: component.componentConfig,
|
||||
config: mergedComponentConfig,
|
||||
componentConfig: mergedComponentConfig,
|
||||
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
|
||||
...(component.componentConfig || {}),
|
||||
...(mergedComponentConfig || {}),
|
||||
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
|
||||
style: mergedStyle,
|
||||
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
|
||||
label: effectiveLabel,
|
||||
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달
|
||||
inputType: (component as any).inputType || component.componentConfig?.inputType,
|
||||
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
|
||||
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
|
||||
columnName: (component as any).columnName || component.componentConfig?.columnName,
|
||||
value: currentValue, // formData에서 추출한 현재 값 전달
|
||||
// 새로운 기능들 전달
|
||||
|
|
@ -608,9 +697,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드)
|
||||
mode: component.componentConfig?.mode || mode,
|
||||
isInModal,
|
||||
readonly: component.readonly,
|
||||
// 🆕 disabledFields 체크 또는 기존 readonly
|
||||
disabled: disabledFields?.includes(fieldName) || component.readonly,
|
||||
readonly: isEntityJoinColumn ? false : component.readonly,
|
||||
disabled: isEntityJoinColumn ? false : (disabledFields?.includes(fieldName) || component.readonly),
|
||||
originalData,
|
||||
allComponents,
|
||||
onUpdateLayout,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -480,15 +480,20 @@ export function RepeaterTable({
|
|||
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
||||
const value = row[column.field];
|
||||
|
||||
// 🆕 카테고리 라벨 변환 함수
|
||||
// 카테고리 라벨 변환 함수
|
||||
const getCategoryDisplayValue = (val: any): string => {
|
||||
if (!val || typeof val !== "string") return val || "-";
|
||||
|
||||
// 카테고리 컬럼이 아니면 그대로 반환
|
||||
const fieldName = column.field.replace(/^_display_/, ""); // _display_ 접두사 제거
|
||||
if (!categoryColumns.includes(fieldName)) return val;
|
||||
const fieldName = column.field.replace(/^_display_/, "");
|
||||
const isCategoryColumn = categoryColumns.includes(fieldName);
|
||||
|
||||
// 쉼표로 구분된 다중 값 처리
|
||||
// categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관)
|
||||
if (categoryLabelMap[val]) return categoryLabelMap[val];
|
||||
|
||||
// 카테고리 컬럼이 아니면 원래 값 반환
|
||||
if (!isCategoryColumn) return val;
|
||||
|
||||
// 콤마 구분된 다중 값 처리
|
||||
const codes = val
|
||||
.split(",")
|
||||
.map((c: string) => c.trim())
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ import { dataApi } from "@/lib/api/data";
|
|||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { apiClient, getFullImageUrl } from "@/lib/api/client";
|
||||
import { getFilePreviewUrl } from "@/lib/api/file";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -39,6 +40,80 @@ import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-opt
|
|||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useSplitPanel } from "./SplitPanelContext";
|
||||
|
||||
// 테이블 셀 이미지 썸네일 컴포넌트
|
||||
const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
||||
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
const rawValue = String(value);
|
||||
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
|
||||
const isObjid = /^\d+$/.test(strValue);
|
||||
|
||||
if (isObjid) {
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
const response = await apiClient.get(`/files/preview/${strValue}`, { responseType: "blob" });
|
||||
if (mounted) {
|
||||
const blob = new Blob([response.data]);
|
||||
setImgSrc(window.URL.createObjectURL(blob));
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) { setError(true); setLoading(false); }
|
||||
}
|
||||
};
|
||||
loadImage();
|
||||
} else {
|
||||
setImgSrc(getFullImageUrl(strValue));
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return () => { mounted = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||
<div className="h-8 w-8 animate-pulse rounded bg-muted sm:h-10 sm:w-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !imgSrc) {
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||
<div className="bg-muted text-muted-foreground flex h-8 w-8 items-center justify-center rounded sm:h-10 sm:w-10" title="이미지를 불러올 수 없습니다">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt="이미지"
|
||||
className="h-8 w-8 cursor-pointer rounded object-cover transition-opacity hover:opacity-80 sm:h-10 sm:w-10"
|
||||
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const rawValue = String(value);
|
||||
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
|
||||
const isObjid = /^\d+$/.test(strValue);
|
||||
window.open(isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue), "_blank");
|
||||
}}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SplitPanelCellImage.displayName = "SplitPanelCellImage";
|
||||
|
||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
}
|
||||
|
|
@ -182,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
||||
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
|
||||
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({}); // 테이블별 컬럼 inputType
|
||||
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
|
||||
Record<string, Record<string, { label: string; color?: string }>>
|
||||
>({}); // 좌측 카테고리 매핑
|
||||
|
|
@ -619,7 +695,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
return result;
|
||||
}, []);
|
||||
|
||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷 + 이미지)
|
||||
const formatCellValue = useCallback(
|
||||
(
|
||||
columnName: string,
|
||||
|
|
@ -636,6 +712,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
) => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
// 이미지 타입: 썸네일 표시
|
||||
const colInputType = columnInputTypes[columnName];
|
||||
if (colInputType === "image" && value) {
|
||||
return <SplitPanelCellImage value={String(value)} />;
|
||||
}
|
||||
|
||||
// 🆕 날짜 포맷 적용
|
||||
if (format?.type === "date" || format?.dateFormat) {
|
||||
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||
|
|
@ -702,7 +784,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 일반 값
|
||||
return String(value);
|
||||
},
|
||||
[formatDateValue, formatNumberValue],
|
||||
[formatDateValue, formatNumberValue, columnInputTypes],
|
||||
);
|
||||
|
||||
// 좌측 데이터 로드
|
||||
|
|
@ -1453,14 +1535,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
});
|
||||
setRightColumnLabels(labels);
|
||||
console.log("✅ 우측 컬럼 라벨 로드:", labels);
|
||||
|
||||
// 우측 테이블 + 추가 탭 테이블의 inputType 로드
|
||||
const tablesToLoad = new Set<string>([rightTableName]);
|
||||
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
|
||||
additionalTabs.forEach((tab: any) => {
|
||||
if (tab.tableName) tablesToLoad.add(tab.tableName);
|
||||
});
|
||||
|
||||
const inputTypes: Record<string, string> = {};
|
||||
for (const tbl of tablesToLoad) {
|
||||
try {
|
||||
const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl);
|
||||
inputTypesResponse.forEach((col: any) => {
|
||||
const colName = col.columnName || col.column_name;
|
||||
if (colName) {
|
||||
inputTypes[colName] = col.inputType || "text";
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// inputType 로드 실패 시 무시
|
||||
}
|
||||
}
|
||||
setColumnInputTypes(inputTypes);
|
||||
} catch (error) {
|
||||
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadRightTableColumns();
|
||||
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
||||
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
|
||||
|
||||
// 좌측 테이블 카테고리 매핑 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -2534,14 +2638,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{group.items.map((item, idx) => {
|
||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
||||
const itemId = item[sourceColumn] || item.id || item.ID;
|
||||
const isSelected =
|
||||
selectedLeftItem &&
|
||||
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={itemId}
|
||||
key={itemId != null ? `${itemId}-${idx}` : idx}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-primary/10" : ""
|
||||
|
|
@ -2596,14 +2700,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{filteredData.map((item, idx) => {
|
||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
||||
const itemId = item[sourceColumn] || item.id || item.ID;
|
||||
const isSelected =
|
||||
selectedLeftItem &&
|
||||
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={itemId}
|
||||
key={itemId != null ? `${itemId}-${idx}` : idx}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-primary/10" : ""
|
||||
|
|
@ -2698,7 +2802,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 재귀 렌더링 함수
|
||||
const renderTreeItem = (item: any, index: number): React.ReactNode => {
|
||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||
const itemId = item[sourceColumn] || item.id || item.ID || index;
|
||||
const rawItemId = item[sourceColumn] || item.id || item.ID;
|
||||
const itemId = rawItemId != null ? rawItemId : index;
|
||||
const isSelected =
|
||||
selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
|
@ -2749,7 +2854,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const displaySubtitle = displayFields[1]?.value || null;
|
||||
|
||||
return (
|
||||
<React.Fragment key={itemId}>
|
||||
<React.Fragment key={`${itemId}-${index}`}>
|
||||
{/* 현재 항목 */}
|
||||
<div
|
||||
className={`group hover:bg-muted relative cursor-pointer rounded-md p-3 transition-colors ${
|
||||
|
|
@ -3081,7 +3186,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
return (
|
||||
<div className="space-y-2">
|
||||
{currentTabData.map((item: any, idx: number) => {
|
||||
const itemId = item.id || idx;
|
||||
const itemId = item.id ?? idx;
|
||||
const isExpanded = expandedRightItems.has(itemId);
|
||||
|
||||
// 표시할 컬럼 결정
|
||||
|
|
@ -3097,7 +3202,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const detailColumns = columnsToShow.slice(summaryCount);
|
||||
|
||||
return (
|
||||
<div key={itemId} className="rounded-lg border bg-white p-3">
|
||||
<div key={`${itemId}-${idx}`} className="rounded-lg border bg-white p-3">
|
||||
<div
|
||||
className="flex cursor-pointer items-start justify-between"
|
||||
onClick={() => toggleRightItemExpansion(itemId)}
|
||||
|
|
@ -3287,10 +3392,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{filteredData.map((item, idx) => {
|
||||
const itemId = item.id || item.ID || idx;
|
||||
const itemId = item.id || item.ID;
|
||||
|
||||
return (
|
||||
<tr key={itemId} className="hover:bg-accent transition-colors">
|
||||
<tr key={itemId != null ? `${itemId}-${idx}` : idx} className="hover:bg-accent transition-colors">
|
||||
{columnsToShow.map((col, colIdx) => (
|
||||
<td
|
||||
key={colIdx}
|
||||
|
|
@ -3404,7 +3509,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
key={`${itemId}-${index}`}
|
||||
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
{/* 요약 정보 */}
|
||||
|
|
|
|||
|
|
@ -781,6 +781,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const dataProvider: DataProvidable = {
|
||||
componentId: component.id,
|
||||
componentType: "table-list",
|
||||
tableName: tableConfig.selectedTable,
|
||||
|
||||
getSelectedData: () => {
|
||||
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
||||
|
|
@ -940,23 +941,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반
|
||||
// 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
|
||||
|
||||
if (response.data.success && response.data.data && response.data.data.length > 0) {
|
||||
return response.data.data.map((item: any) => ({
|
||||
value: String(item.value),
|
||||
label: String(item.label),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
|
||||
}
|
||||
|
||||
// fallback: 현재 로드된 데이터에서 고유 값 추출
|
||||
const isLabelType = ["category", "entity", "code"].includes(inputType);
|
||||
const labelField = isLabelType ? `${columnName}_name` : columnName;
|
||||
|
||||
// 현재 로드된 데이터에서 고유 값 추출
|
||||
const uniqueValuesMap = new Map<string, string>(); // value -> label
|
||||
const uniqueValuesMap = new Map<string, string>();
|
||||
|
||||
data.forEach((row) => {
|
||||
const value = row[columnName];
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
|
||||
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
|
||||
uniqueValuesMap.set(String(value), label);
|
||||
}
|
||||
});
|
||||
|
||||
// Map을 배열로 변환하고 라벨 기준으로 정렬
|
||||
const result = Array.from(uniqueValuesMap.entries())
|
||||
.map(([value, label]) => ({
|
||||
value: value,
|
||||
|
|
@ -4192,9 +4205,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
||||
const inputType = meta?.inputType || column.inputType;
|
||||
|
||||
// 🖼️ 이미지 타입: 작은 썸네일 표시
|
||||
// 🖼️ 이미지 타입: 작은 썸네일 표시 (다중 이미지인 경우 대표 이미지 1개만)
|
||||
if (inputType === "image" && value && typeof value === "string") {
|
||||
const imageUrl = getFullImageUrl(value);
|
||||
const firstImage = value.includes(",") ? value.split(",")[0].trim() : value;
|
||||
const imageUrl = getFullImageUrl(firstImage);
|
||||
return (
|
||||
<img
|
||||
src={imageUrl}
|
||||
|
|
@ -4307,7 +4321,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 다중 값인 경우: 여러 배지 렌더링
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="flex flex-nowrap gap-1 overflow-hidden">
|
||||
{values.map((val, idx) => {
|
||||
const categoryData = mapping?.[val];
|
||||
const displayLabel = categoryData?.label || val;
|
||||
|
|
@ -4316,7 +4330,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||||
if (!displayColor || displayColor === "none" || !categoryData) {
|
||||
return (
|
||||
<span key={idx} className="text-sm">
|
||||
<span key={idx} className="shrink-0 whitespace-nowrap text-sm">
|
||||
{displayLabel}
|
||||
{idx < values.length - 1 && ", "}
|
||||
</span>
|
||||
|
|
@ -4330,7 +4344,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
backgroundColor: displayColor,
|
||||
borderColor: displayColor,
|
||||
}}
|
||||
className="text-white"
|
||||
className="shrink-0 whitespace-nowrap text-white"
|
||||
>
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
|
|
|
|||
|
|
@ -554,6 +554,69 @@ export function TableSectionRenderer({
|
|||
loadCategoryOptions();
|
||||
}, [tableConfig.source.tableName, tableConfig.columns]);
|
||||
|
||||
// receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드
|
||||
useEffect(() => {
|
||||
if (!formData || Object.keys(formData).length === 0) return;
|
||||
if (!tableConfig.columns) return;
|
||||
|
||||
const codesToResolve: string[] = [];
|
||||
for (const col of tableConfig.columns) {
|
||||
// receiveFromParent 컬럼
|
||||
if ((col as any).receiveFromParent) {
|
||||
const parentField = (col as any).parentFieldName || col.field;
|
||||
const val = formData[parentField];
|
||||
if (typeof val === "string" && val) {
|
||||
codesToResolve.push(val);
|
||||
}
|
||||
}
|
||||
// internal 매핑 컬럼
|
||||
const mapping = (col as any).valueMapping;
|
||||
if (mapping?.type === "internal" && mapping.internalField) {
|
||||
const val = formData[mapping.internalField];
|
||||
if (typeof val === "string" && val) {
|
||||
codesToResolve.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (codesToResolve.length === 0) return;
|
||||
|
||||
const loadParentLabels = async () => {
|
||||
try {
|
||||
const resp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||
valueCodes: codesToResolve,
|
||||
});
|
||||
if (resp.data?.success && resp.data.data) {
|
||||
const labelData = resp.data.data as Record<string, string>;
|
||||
// categoryOptionsMap에 추가 (receiveFromParent 컬럼별로)
|
||||
const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
|
||||
for (const col of tableConfig.columns) {
|
||||
let val: string | undefined;
|
||||
if ((col as any).receiveFromParent) {
|
||||
const parentField = (col as any).parentFieldName || col.field;
|
||||
val = formData[parentField] as string;
|
||||
}
|
||||
const mapping = (col as any).valueMapping;
|
||||
if (mapping?.type === "internal" && mapping.internalField) {
|
||||
val = formData[mapping.internalField] as string;
|
||||
}
|
||||
if (val && typeof val === "string" && labelData[val]) {
|
||||
newOptionsMap[col.field] = [{ value: val, label: labelData[val] }];
|
||||
}
|
||||
}
|
||||
if (Object.keys(newOptionsMap).length > 0) {
|
||||
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 라벨 조회 실패 시 무시
|
||||
}
|
||||
};
|
||||
|
||||
loadParentLabels();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData, tableConfig.columns]);
|
||||
|
||||
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
|
||||
useEffect(() => {
|
||||
if (!isConditionalMode) return;
|
||||
|
|
@ -1005,6 +1068,23 @@ export function TableSectionRenderer({
|
|||
});
|
||||
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
|
||||
|
||||
// categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생
|
||||
const tableCategoryColumns = useMemo(() => {
|
||||
return Object.keys(categoryOptionsMap);
|
||||
}, [categoryOptionsMap]);
|
||||
|
||||
const tableCategoryLabelMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const options of Object.values(categoryOptionsMap)) {
|
||||
for (const opt of options) {
|
||||
if (opt.value && opt.label) {
|
||||
map[opt.value] = opt.label;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [categoryOptionsMap]);
|
||||
|
||||
// 원본 계산 규칙 (조건부 계산 포함)
|
||||
const originalCalculationRules: TableCalculationRule[] = useMemo(
|
||||
() => tableConfig.calculations || [],
|
||||
|
|
@ -1312,6 +1392,67 @@ export function TableSectionRenderer({
|
|||
}),
|
||||
);
|
||||
|
||||
// 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용)
|
||||
const categoryFields = (tableConfig.columns || [])
|
||||
.filter((col) => col.type === "category" || col.type === "select")
|
||||
.reduce<Record<string, Record<string, string>>>((acc, col) => {
|
||||
const options = categoryOptionsMap[col.field];
|
||||
if (options && options.length > 0) {
|
||||
acc[col.field] = {};
|
||||
for (const opt of options) {
|
||||
acc[col.field][opt.value] = opt.label;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// receiveFromParent / internal 매핑으로 넘어온 값도 포함하여 변환
|
||||
if (Object.keys(categoryFields).length > 0) {
|
||||
for (const item of mappedItems) {
|
||||
for (const [field, codeToLabel] of Object.entries(categoryFields)) {
|
||||
const val = item[field];
|
||||
if (typeof val === "string" && codeToLabel[val]) {
|
||||
item[field] = codeToLabel[val];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// categoryOptionsMap에 없는 경우 API fallback
|
||||
const unresolvedCodes = new Set<string>();
|
||||
const categoryColFields = new Set(
|
||||
(tableConfig.columns || []).filter((col) => col.type === "category").map((col) => col.field),
|
||||
);
|
||||
for (const item of mappedItems) {
|
||||
for (const field of categoryColFields) {
|
||||
const val = item[field];
|
||||
if (typeof val === "string" && val && !categoryFields[field]?.[val] && val !== item[field]) {
|
||||
unresolvedCodes.add(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolvedCodes.size > 0) {
|
||||
try {
|
||||
const labelResp = await apiClient.post("/table-categories/labels-by-codes", {
|
||||
valueCodes: Array.from(unresolvedCodes),
|
||||
});
|
||||
if (labelResp.data?.success && labelResp.data.data) {
|
||||
const labelData = labelResp.data.data as Record<string, string>;
|
||||
for (const item of mappedItems) {
|
||||
for (const field of categoryColFields) {
|
||||
const val = item[field];
|
||||
if (typeof val === "string" && labelData[val]) {
|
||||
item[field] = labelData[val];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 변환 실패 시 코드 유지
|
||||
}
|
||||
}
|
||||
|
||||
// 계산 필드 업데이트
|
||||
const calculatedItems = calculateAll(mappedItems);
|
||||
|
||||
|
|
@ -1319,7 +1460,7 @@ export function TableSectionRenderer({
|
|||
const newData = [...tableData, ...calculatedItems];
|
||||
handleDataChange(newData);
|
||||
},
|
||||
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources],
|
||||
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources, categoryOptionsMap],
|
||||
);
|
||||
|
||||
// 컬럼 모드/조회 옵션 변경 핸들러
|
||||
|
|
@ -1667,6 +1808,31 @@ export function TableSectionRenderer({
|
|||
}),
|
||||
);
|
||||
|
||||
// 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용)
|
||||
const categoryFields = (tableConfig.columns || [])
|
||||
.filter((col) => col.type === "category" || col.type === "select")
|
||||
.reduce<Record<string, Record<string, string>>>((acc, col) => {
|
||||
const options = categoryOptionsMap[col.field];
|
||||
if (options && options.length > 0) {
|
||||
acc[col.field] = {};
|
||||
for (const opt of options) {
|
||||
acc[col.field][opt.value] = opt.label;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (Object.keys(categoryFields).length > 0) {
|
||||
for (const item of mappedItems) {
|
||||
for (const [field, codeToLabel] of Object.entries(categoryFields)) {
|
||||
const val = item[field];
|
||||
if (typeof val === "string" && codeToLabel[val]) {
|
||||
item[field] = codeToLabel[val];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 조건의 데이터에 추가
|
||||
const currentData = conditionalTableData[modalCondition] || [];
|
||||
const newData = [...currentData, ...mappedItems];
|
||||
|
|
@ -1964,6 +2130,8 @@ export function TableSectionRenderer({
|
|||
[conditionValue]: newSelected,
|
||||
}));
|
||||
}}
|
||||
categoryColumns={tableCategoryColumns}
|
||||
categoryLabelMap={tableCategoryLabelMap}
|
||||
equalizeWidthsTrigger={widthTrigger}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
|
@ -2055,6 +2223,8 @@ export function TableSectionRenderer({
|
|||
}));
|
||||
}}
|
||||
equalizeWidthsTrigger={widthTrigger}
|
||||
categoryColumns={tableCategoryColumns}
|
||||
categoryLabelMap={tableCategoryLabelMap}
|
||||
/>
|
||||
</TabsContent>
|
||||
);
|
||||
|
|
@ -2185,6 +2355,8 @@ export function TableSectionRenderer({
|
|||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
equalizeWidthsTrigger={widthTrigger}
|
||||
categoryColumns={tableCategoryColumns}
|
||||
categoryLabelMap={tableCategoryLabelMap}
|
||||
/>
|
||||
|
||||
{/* 항목 선택 모달 */}
|
||||
|
|
|
|||
|
|
@ -247,6 +247,10 @@ export function UniversalFormModalComponent({
|
|||
|
||||
// 폼 데이터 상태
|
||||
const [formData, setFormData] = useState<FormDataState>({});
|
||||
// formDataRef: 항상 최신 formData를 유지하는 ref
|
||||
// React 상태 업데이트는 비동기적이므로, handleBeforeFormSave 등에서
|
||||
// 클로저의 formData가 오래된 값을 참조하는 문제를 방지
|
||||
const formDataRef = useRef<FormDataState>({});
|
||||
const [, setOriginalData] = useState<Record<string, any>>({});
|
||||
|
||||
// 반복 섹션 데이터
|
||||
|
|
@ -398,18 +402,19 @@ export function UniversalFormModalComponent({
|
|||
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
||||
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
||||
|
||||
// formDataRef.current 사용: blur+click 동시 처리 시 클로저의 formData가 오래된 문제 방지
|
||||
const latestFormData = formDataRef.current;
|
||||
|
||||
// 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용)
|
||||
// - 신규 등록: formData.id가 없으므로 영향 없음
|
||||
// - 편집 모드: formData.id가 있으면 메인 테이블 UPDATE에 사용
|
||||
if (formData.id !== undefined && formData.id !== null && formData.id !== "") {
|
||||
event.detail.formData.id = formData.id;
|
||||
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, formData.id);
|
||||
if (latestFormData.id !== undefined && latestFormData.id !== null && latestFormData.id !== "") {
|
||||
event.detail.formData.id = latestFormData.id;
|
||||
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, latestFormData.id);
|
||||
}
|
||||
|
||||
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
||||
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
||||
// (UniversalFormModal이 해당 필드의 주인이므로)
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
for (const [key, value] of Object.entries(latestFormData)) {
|
||||
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
|
||||
const isConfiguredField = configuredFields.has(key);
|
||||
const isNumberingRuleId = key.endsWith("_numberingRuleId");
|
||||
|
|
@ -432,17 +437,13 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
|
||||
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
|
||||
// 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블),
|
||||
// handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
// 싱글/더블 언더스코어 모두 처리
|
||||
// formDataRef.current(= latestFormData) 사용: React 상태 커밋 전에도 최신 데이터 보장
|
||||
for (const [key, value] of Object.entries(latestFormData)) {
|
||||
// _tableSection_ 과 __tableSection_ 모두 원본 키 그대로 전달
|
||||
// buttonActions.ts에서 DB데이터(__tableSection_)와 수정데이터(_tableSection_)를 구분하여 병합
|
||||
if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) {
|
||||
// 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대)
|
||||
const normalizedKey = key.startsWith("__tableSection_")
|
||||
? key.replace("__tableSection_", "_tableSection_")
|
||||
: key;
|
||||
event.detail.formData[normalizedKey] = value;
|
||||
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key} → ${normalizedKey}, ${value.length}개 항목`);
|
||||
event.detail.formData[key] = value;
|
||||
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`);
|
||||
}
|
||||
|
||||
// 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용)
|
||||
|
|
@ -457,6 +458,22 @@ export function UniversalFormModalComponent({
|
|||
event.detail.formData._originalGroupedData = originalGroupedData;
|
||||
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`);
|
||||
}
|
||||
|
||||
// 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트
|
||||
// onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트
|
||||
for (const parentKey of Object.keys(event.detail.formData)) {
|
||||
const parentValue = event.detail.formData[parentKey];
|
||||
if (parentValue && typeof parentValue === "object" && !Array.isArray(parentValue)) {
|
||||
const hasTableSection = Object.keys(parentValue).some(
|
||||
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
|
||||
);
|
||||
if (hasTableSection) {
|
||||
event.detail.formData[parentKey] = { ...latestFormData };
|
||||
console.log(`[UniversalFormModal] 부모 중첩 객체 업데이트: ${parentKey}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||
|
|
@ -482,10 +499,11 @@ export function UniversalFormModalComponent({
|
|||
|
||||
// 테이블 섹션 데이터 설정
|
||||
const tableSectionKey = `_tableSection_${tableSection.id}`;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[tableSectionKey]: _groupedData,
|
||||
}));
|
||||
setFormData((prev) => {
|
||||
const newData = { ...prev, [tableSectionKey]: _groupedData };
|
||||
formDataRef.current = newData;
|
||||
return newData;
|
||||
});
|
||||
|
||||
groupedDataInitializedRef.current = true;
|
||||
}, [_groupedData, config.sections]);
|
||||
|
|
@ -965,6 +983,7 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
|
||||
setFormData(newFormData);
|
||||
formDataRef.current = newFormData;
|
||||
setRepeatSections(newRepeatSections);
|
||||
setCollapsedSections(newCollapsed);
|
||||
setActivatedOptionalFieldGroups(newActivatedGroups);
|
||||
|
|
@ -1132,6 +1151,9 @@ export function UniversalFormModalComponent({
|
|||
console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`);
|
||||
}
|
||||
|
||||
// ref 즉시 업데이트 (React 상태 커밋 전에도 최신 데이터 접근 가능)
|
||||
formDataRef.current = newData;
|
||||
|
||||
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
|
||||
if (onChange) {
|
||||
setTimeout(() => onChange(newData), 0);
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ export interface TableModalFilter {
|
|||
export interface TableColumnConfig {
|
||||
field: string; // 필드명 (저장할 컬럼명)
|
||||
label: string; // 컬럼 헤더 라벨
|
||||
type: "text" | "number" | "date" | "select"; // 입력 타입
|
||||
type: "text" | "number" | "date" | "select" | "category"; // 입력 타입
|
||||
|
||||
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
|
||||
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
GripVertical,
|
||||
Plus,
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -26,6 +27,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
|
@ -82,7 +84,7 @@ const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`;
|
|||
interface ItemSearchModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (item: ItemInfo) => void;
|
||||
onSelect: (items: ItemInfo[]) => void;
|
||||
companyCode?: string;
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +96,7 @@ function ItemSearchModal({
|
|||
}: ItemSearchModalProps) {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [items, setItems] = useState<ItemInfo[]>([]);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const searchItems = useCallback(
|
||||
|
|
@ -109,7 +112,7 @@ function ItemSearchModal({
|
|||
enableEntityJoin: true,
|
||||
companyCodeOverride: companyCode,
|
||||
});
|
||||
setItems(result.data || []);
|
||||
setItems((result.data || []) as ItemInfo[]);
|
||||
} catch (error) {
|
||||
console.error("[BomItemEditor] 품목 검색 실패:", error);
|
||||
} finally {
|
||||
|
|
@ -122,6 +125,7 @@ function ItemSearchModal({
|
|||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchText("");
|
||||
setSelectedItems(new Set());
|
||||
searchItems("");
|
||||
}
|
||||
}, [open, searchItems]);
|
||||
|
|
@ -180,6 +184,15 @@ function ItemSearchModal({
|
|||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="w-8 px-2 py-2 text-center">
|
||||
<Checkbox
|
||||
checked={selectedItems.size > 0 && selectedItems.size === items.length}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setSelectedItems(new Set(items.map((i) => i.id)));
|
||||
else setSelectedItems(new Set());
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">품목코드</th>
|
||||
<th className="px-3 py-2 text-left font-medium">품목명</th>
|
||||
<th className="px-3 py-2 text-left font-medium">구분</th>
|
||||
|
|
@ -191,11 +204,31 @@ function ItemSearchModal({
|
|||
<tr
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
onSelect(item);
|
||||
onClose();
|
||||
setSelectedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id);
|
||||
else next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="hover:bg-accent cursor-pointer border-t transition-colors"
|
||||
className={cn(
|
||||
"cursor-pointer border-t transition-colors",
|
||||
selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(item.id);
|
||||
else next.delete(item.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono">
|
||||
{item.item_number}
|
||||
</td>
|
||||
|
|
@ -208,6 +241,25 @@ function ItemSearchModal({
|
|||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedItems.size > 0 && (
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">
|
||||
{selectedItems.size}개 선택됨
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const selected = items.filter((i) => selectedItems.has(i.id));
|
||||
onSelect(selected);
|
||||
onClose();
|
||||
}}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
추가
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
@ -227,6 +279,10 @@ interface TreeNodeRowProps {
|
|||
onFieldChange: (tempId: string, field: string, value: string) => void;
|
||||
onDelete: (tempId: string) => void;
|
||||
onAddChild: (parentTempId: string) => void;
|
||||
onDragStart: (e: React.DragEvent, tempId: string) => void;
|
||||
onDragOver: (e: React.DragEvent, tempId: string) => void;
|
||||
onDrop: (e: React.DragEvent, tempId: string) => void;
|
||||
isDragOver?: boolean;
|
||||
}
|
||||
|
||||
function TreeNodeRow({
|
||||
|
|
@ -241,6 +297,10 @@ function TreeNodeRow({
|
|||
onFieldChange,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
isDragOver,
|
||||
}: TreeNodeRowProps) {
|
||||
const indentPx = depth * 32;
|
||||
const visibleColumns = columns.filter((c) => c.visible !== false);
|
||||
|
|
@ -319,8 +379,13 @@ function TreeNodeRow({
|
|||
"group flex items-center gap-2 rounded-md border px-2 py-1.5",
|
||||
"transition-colors hover:bg-accent/30",
|
||||
depth > 0 && "ml-2 border-l-2 border-l-primary/20",
|
||||
isDragOver && "border-primary bg-primary/5 border-dashed",
|
||||
)}
|
||||
style={{ marginLeft: `${indentPx}px` }}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, node.tempId)}
|
||||
onDragOver={(e) => onDragOver(e, node.tempId)}
|
||||
onDrop={(e) => onDrop(e, node.tempId)}
|
||||
>
|
||||
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
|
||||
|
||||
|
|
@ -409,7 +474,7 @@ export function BomItemEditorComponent({
|
|||
// 설정값 추출
|
||||
const cfg = useMemo(() => component?.componentConfig || {}, [component]);
|
||||
const mainTableName = cfg.mainTableName || "bom_detail";
|
||||
const parentKeyColumn = cfg.parentKeyColumn || "parent_detail_id";
|
||||
const parentKeyColumn = (cfg.parentKeyColumn && cfg.parentKeyColumn !== "id") ? cfg.parentKeyColumn : "parent_detail_id";
|
||||
const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]);
|
||||
const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]);
|
||||
const fkColumn = cfg.foreignKeyColumn || "bom_id";
|
||||
|
|
@ -422,6 +487,28 @@ export function BomItemEditorComponent({
|
|||
return null;
|
||||
}, [propBomId, formData, selectedRowsData]);
|
||||
|
||||
// BOM 전용 API로 현재 current_version_id 조회
|
||||
const fetchCurrentVersionId = useCallback(async (id: string): Promise<string | null> => {
|
||||
try {
|
||||
const res = await apiClient.get(`/bom/${id}/versions`);
|
||||
if (res.data?.success) {
|
||||
// bom.current_version_id를 직접 반환 (불러오기와 사용확정 구분)
|
||||
if (res.data.currentVersionId) return res.data.currentVersionId;
|
||||
// fallback: active 상태 버전
|
||||
const activeVersion = res.data.data?.find((v: any) => v.status === "active");
|
||||
if (activeVersion) return activeVersion.id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[BomItemEditor] current_version_id 조회 실패:", e);
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// formData에서 가져오는 versionId (fallback용)
|
||||
const propsVersionId = (formData?.current_version_id as string)
|
||||
|| (selectedRowsData?.[0]?.current_version_id as string)
|
||||
|| null;
|
||||
|
||||
// ─── 카테고리 옵션 로드 (리피터 방식) ───
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -431,7 +518,14 @@ export function BomItemEditorComponent({
|
|||
|
||||
for (const col of categoryColumns) {
|
||||
const categoryRef = `${mainTableName}.${col.key}`;
|
||||
if (categoryOptionsMap[categoryRef]) continue;
|
||||
|
||||
const alreadyLoaded = await new Promise<boolean>((resolve) => {
|
||||
setCategoryOptionsMap((prev) => {
|
||||
resolve(!!prev[categoryRef]);
|
||||
return prev;
|
||||
});
|
||||
});
|
||||
if (alreadyLoaded) continue;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`);
|
||||
|
|
@ -455,21 +549,58 @@ export function BomItemEditorComponent({
|
|||
|
||||
// ─── 데이터 로드 ───
|
||||
|
||||
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
|
||||
const sourceTable = cfg.dataSource?.sourceTable || "item_info";
|
||||
|
||||
const loadBomDetails = useCallback(
|
||||
async (id: string) => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getTableDataWithJoins(mainTableName, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
search: { [fkColumn]: id },
|
||||
sortBy: "seq_no",
|
||||
sortOrder: "asc",
|
||||
enableEntityJoin: true,
|
||||
// isSourceDisplay 컬럼을 추가 조인 컬럼으로 요청
|
||||
const displayCols = columns.filter((c) => c.isSourceDisplay);
|
||||
const additionalJoinColumns = displayCols.map((col) => ({
|
||||
sourceTable,
|
||||
sourceColumn: sourceFk,
|
||||
joinAlias: `${sourceFk}_${col.key}`,
|
||||
referenceTable: sourceTable,
|
||||
}));
|
||||
|
||||
// 서버에서 최신 current_version_id 조회 (항상 최신 보장)
|
||||
const freshVersionId = await fetchCurrentVersionId(id);
|
||||
const effectiveVersionId = freshVersionId || propsVersionId;
|
||||
|
||||
const searchFilter: Record<string, any> = { [fkColumn]: id };
|
||||
if (effectiveVersionId) {
|
||||
searchFilter.version_id = effectiveVersionId;
|
||||
}
|
||||
|
||||
// autoFilter 비활성화: BOM 전용 API로 company_code 관리
|
||||
const res = await apiClient.get(`/table-management/tables/${mainTableName}/data-with-joins`, {
|
||||
params: {
|
||||
page: 1,
|
||||
size: 500,
|
||||
search: JSON.stringify(searchFilter),
|
||||
sortBy: "seq_no",
|
||||
sortOrder: "asc",
|
||||
enableEntityJoin: true,
|
||||
additionalJoinColumns: additionalJoinColumns.length > 0 ? JSON.stringify(additionalJoinColumns) : undefined,
|
||||
autoFilter: JSON.stringify({ enabled: false }),
|
||||
},
|
||||
});
|
||||
|
||||
const rawData = res.data?.data?.data || res.data?.data || [];
|
||||
const rows = (Array.isArray(rawData) ? rawData : []).map((row: Record<string, any>) => {
|
||||
const mapped = { ...row };
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key.startsWith(`${sourceFk}_`)) {
|
||||
const shortKey = key.replace(`${sourceFk}_`, "");
|
||||
if (!mapped[shortKey]) mapped[shortKey] = row[key];
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
|
||||
const rows = result.data || [];
|
||||
const tree = buildTree(rows);
|
||||
setTreeData(tree);
|
||||
|
||||
|
|
@ -483,14 +614,20 @@ export function BomItemEditorComponent({
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[mainTableName, fkColumn],
|
||||
[mainTableName, fkColumn, sourceFk, sourceTable, columns, fetchCurrentVersionId, propsVersionId],
|
||||
);
|
||||
|
||||
// formData.current_version_id가 변경될 때도 재로드 (버전 전환 시 반영)
|
||||
const formVersionRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (bomId && !isDesignMode) {
|
||||
if (!bomId || isDesignMode) return;
|
||||
const currentFormVersion = formData?.current_version_id as string || null;
|
||||
// bomId가 바뀌거나, formData의 current_version_id가 바뀌면 재로드
|
||||
if (formVersionRef.current !== currentFormVersion || !formVersionRef.current) {
|
||||
formVersionRef.current = currentFormVersion;
|
||||
loadBomDetails(bomId);
|
||||
}
|
||||
}, [bomId, isDesignMode, loadBomDetails]);
|
||||
}, [bomId, isDesignMode, loadBomDetails, formData?.current_version_id]);
|
||||
|
||||
// ─── 트리 빌드 (동적 데이터) ───
|
||||
|
||||
|
|
@ -548,10 +685,13 @@ export function BomItemEditorComponent({
|
|||
id: node.id,
|
||||
tempId: node.tempId,
|
||||
[parentKeyColumn]: parentId,
|
||||
[fkColumn]: bomId,
|
||||
seq_no: String(idx + 1),
|
||||
level: String(level),
|
||||
_isNew: node._isNew,
|
||||
_targetTable: mainTableName,
|
||||
_fkColumn: fkColumn,
|
||||
_deferSave: true,
|
||||
});
|
||||
if (node.children.length > 0) {
|
||||
traverse(node.children, node.id || node.tempId, level + 1);
|
||||
|
|
@ -560,7 +700,7 @@ export function BomItemEditorComponent({
|
|||
};
|
||||
traverse(nodes, null, 0);
|
||||
return result;
|
||||
}, [parentKeyColumn, mainTableName]);
|
||||
}, [parentKeyColumn, mainTableName, fkColumn, bomId]);
|
||||
|
||||
// 트리 변경 시 부모에게 알림
|
||||
const notifyChange = useCallback(
|
||||
|
|
@ -571,6 +711,164 @@ export function BomItemEditorComponent({
|
|||
[onChange, flattenTree],
|
||||
);
|
||||
|
||||
// ─── DB 저장 (INSERT/UPDATE/DELETE 일괄) ───
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const originalDataRef = React.useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (treeData.length > 0 && originalDataRef.current.size === 0) {
|
||||
const collectIds = (nodes: BomItemNode[]) => {
|
||||
nodes.forEach((n) => {
|
||||
if (n.id) originalDataRef.current.add(n.id);
|
||||
collectIds(n.children);
|
||||
});
|
||||
};
|
||||
collectIds(treeData);
|
||||
}
|
||||
}, [treeData]);
|
||||
|
||||
const markChanged = useCallback(() => setHasChanges(true), []);
|
||||
const originalNotifyChange = notifyChange;
|
||||
const notifyChangeWithDirty = useCallback(
|
||||
(newTree: BomItemNode[]) => {
|
||||
originalNotifyChange(newTree);
|
||||
markChanged();
|
||||
},
|
||||
[originalNotifyChange, markChanged],
|
||||
);
|
||||
|
||||
// EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장
|
||||
useEffect(() => {
|
||||
if (isDesignMode || !bomId) return;
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", {
|
||||
bomId,
|
||||
treeDataLength: treeData.length,
|
||||
hasRef: !!handleSaveAllRef.current,
|
||||
});
|
||||
if (treeData.length > 0 && handleSaveAllRef.current) {
|
||||
const savePromise = handleSaveAllRef.current();
|
||||
if (detail?.pendingPromises) {
|
||||
detail.pendingPromises.push(savePromise);
|
||||
console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료");
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeFormSave", handler);
|
||||
console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode });
|
||||
return () => window.removeEventListener("beforeFormSave", handler);
|
||||
}, [isDesignMode, bomId, treeData.length]);
|
||||
|
||||
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
const handleSaveAll = useCallback(async () => {
|
||||
if (!bomId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// 저장 시점에도 최신 version_id 조회
|
||||
const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
|
||||
|
||||
const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => {
|
||||
const result: any[] = [];
|
||||
nodes.forEach((node, idx) => {
|
||||
result.push({
|
||||
node,
|
||||
parentRealId,
|
||||
level,
|
||||
seqNo: idx + 1,
|
||||
});
|
||||
if (node.children.length > 0) {
|
||||
result.push(...collectAll(node.children, node.id || node.tempId, level + 1));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const allNodes = collectAll(treeData, null, 0);
|
||||
const tempToReal: Record<string, string> = {};
|
||||
let savedCount = 0;
|
||||
|
||||
for (const { node, parentRealId, level, seqNo } of allNodes) {
|
||||
const realParentId = parentRealId
|
||||
? tempToReal[parentRealId] || parentRealId
|
||||
: null;
|
||||
|
||||
if (node._isNew) {
|
||||
const payload: Record<string, any> = {
|
||||
...node.data,
|
||||
[fkColumn]: bomId,
|
||||
[parentKeyColumn]: realParentId,
|
||||
seq_no: String(seqNo),
|
||||
level: String(level),
|
||||
company_code: companyCode || undefined,
|
||||
version_id: saveVersionId || undefined,
|
||||
};
|
||||
delete payload.id;
|
||||
delete payload.tempId;
|
||||
delete payload._isNew;
|
||||
delete payload._isDeleted;
|
||||
|
||||
const resp = await apiClient.post(
|
||||
`/table-management/tables/${mainTableName}/add`,
|
||||
payload,
|
||||
);
|
||||
const newId = resp.data?.data?.id;
|
||||
if (newId) tempToReal[node.tempId] = newId;
|
||||
savedCount++;
|
||||
} else if (node.id) {
|
||||
const updatedData: Record<string, any> = {
|
||||
...node.data,
|
||||
id: node.id,
|
||||
[parentKeyColumn]: realParentId,
|
||||
seq_no: String(seqNo),
|
||||
level: String(level),
|
||||
};
|
||||
delete updatedData.tempId;
|
||||
delete updatedData._isNew;
|
||||
delete updatedData._isDeleted;
|
||||
Object.keys(updatedData).forEach((k) => {
|
||||
if (k.startsWith(`${sourceFk}_`)) delete updatedData[k];
|
||||
});
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${mainTableName}/edit`,
|
||||
{ originalData: { id: node.id }, updatedData },
|
||||
);
|
||||
savedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const currentIds = new Set(allNodes.filter((a) => a.node.id).map((a) => a.node.id));
|
||||
for (const oldId of originalDataRef.current) {
|
||||
if (!currentIds.has(oldId)) {
|
||||
await apiClient.delete(
|
||||
`/table-management/tables/${mainTableName}/delete`,
|
||||
{ data: [{ id: oldId }] },
|
||||
);
|
||||
savedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
originalDataRef.current = new Set(allNodes.filter((a) => a.node.id || tempToReal[a.node.tempId]).map((a) => a.node.id || tempToReal[a.node.tempId]));
|
||||
setHasChanges(false);
|
||||
if (bomId) loadBomDetails(bomId);
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
console.log(`[BomItemEditor] ${savedCount}건 저장 완료`);
|
||||
} catch (error) {
|
||||
console.error("[BomItemEditor] 저장 실패:", error);
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [bomId, treeData, fkColumn, parentKeyColumn, mainTableName, companyCode, sourceFk, loadBomDetails, fetchCurrentVersionId, propsVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
handleSaveAllRef.current = handleSaveAll;
|
||||
}, [handleSaveAll]);
|
||||
|
||||
// ─── 노드 조작 함수들 ───
|
||||
|
||||
// 트리에서 특정 노드 찾기 (재귀)
|
||||
|
|
@ -601,18 +899,18 @@ export function BomItemEditorComponent({
|
|||
...node,
|
||||
data: { ...node.data, [field]: value },
|
||||
}));
|
||||
notifyChange(newTree);
|
||||
notifyChangeWithDirty(newTree);
|
||||
},
|
||||
[treeData, notifyChange],
|
||||
[treeData, notifyChangeWithDirty],
|
||||
);
|
||||
|
||||
// 노드 삭제
|
||||
const handleDelete = useCallback(
|
||||
(tempId: string) => {
|
||||
const newTree = findAndUpdate(treeData, tempId, () => null);
|
||||
notifyChange(newTree);
|
||||
notifyChangeWithDirty(newTree);
|
||||
},
|
||||
[treeData, notifyChange],
|
||||
[treeData, notifyChangeWithDirty],
|
||||
);
|
||||
|
||||
// 하위 품목 추가 시작 (모달 열기)
|
||||
|
|
@ -627,59 +925,62 @@ export function BomItemEditorComponent({
|
|||
setItemSearchOpen(true);
|
||||
}, []);
|
||||
|
||||
// 품목 선택 후 추가 (동적 데이터)
|
||||
// 품목 선택 후 추가 (다중 선택 지원)
|
||||
const handleItemSelect = useCallback(
|
||||
(item: ItemInfo) => {
|
||||
// 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식)
|
||||
const sourceData: Record<string, any> = {};
|
||||
const sourceTable = cfg.dataSource?.sourceTable;
|
||||
if (sourceTable) {
|
||||
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
|
||||
sourceData[sourceFk] = item.id;
|
||||
// 소스 표시 컬럼의 데이터 병합
|
||||
Object.keys(item).forEach((key) => {
|
||||
sourceData[`_display_${key}`] = (item as any)[key];
|
||||
sourceData[key] = (item as any)[key];
|
||||
});
|
||||
(selectedItemsList: ItemInfo[]) => {
|
||||
let newTree = [...treeData];
|
||||
|
||||
for (const item of selectedItemsList) {
|
||||
const sourceData: Record<string, any> = {};
|
||||
const sourceTable = cfg.dataSource?.sourceTable;
|
||||
if (sourceTable) {
|
||||
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
|
||||
sourceData[sourceFk] = item.id;
|
||||
Object.keys(item).forEach((key) => {
|
||||
sourceData[`_display_${key}`] = (item as any)[key];
|
||||
sourceData[key] = (item as any)[key];
|
||||
});
|
||||
}
|
||||
|
||||
const newNode: BomItemNode = {
|
||||
tempId: generateTempId(),
|
||||
parent_detail_id: null,
|
||||
seq_no: 0,
|
||||
level: 0,
|
||||
children: [],
|
||||
_isNew: true,
|
||||
data: {
|
||||
...sourceData,
|
||||
quantity: "1",
|
||||
loss_rate: "0",
|
||||
remark: "",
|
||||
},
|
||||
};
|
||||
|
||||
if (addTargetParentId === null) {
|
||||
newNode.seq_no = newTree.length + 1;
|
||||
newNode.level = 0;
|
||||
newTree = [...newTree, newNode];
|
||||
} else {
|
||||
newTree = findAndUpdate(newTree, addTargetParentId, (parent) => {
|
||||
newNode.parent_detail_id = parent.id || parent.tempId;
|
||||
newNode.seq_no = parent.children.length + 1;
|
||||
newNode.level = parent.level + 1;
|
||||
return {
|
||||
...parent,
|
||||
children: [...parent.children, newNode],
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newNode: BomItemNode = {
|
||||
tempId: generateTempId(),
|
||||
parent_detail_id: null,
|
||||
seq_no: 0,
|
||||
level: 0,
|
||||
children: [],
|
||||
_isNew: true,
|
||||
data: {
|
||||
...sourceData,
|
||||
quantity: "1",
|
||||
loss_rate: "0",
|
||||
remark: "",
|
||||
},
|
||||
};
|
||||
|
||||
let newTree: BomItemNode[];
|
||||
|
||||
if (addTargetParentId === null) {
|
||||
newNode.seq_no = treeData.length + 1;
|
||||
newNode.level = 0;
|
||||
newTree = [...treeData, newNode];
|
||||
} else {
|
||||
newTree = findAndUpdate(treeData, addTargetParentId, (parent) => {
|
||||
newNode.parent_detail_id = parent.id || parent.tempId;
|
||||
newNode.seq_no = parent.children.length + 1;
|
||||
newNode.level = parent.level + 1;
|
||||
return {
|
||||
...parent,
|
||||
children: [...parent.children, newNode],
|
||||
};
|
||||
});
|
||||
if (addTargetParentId !== null) {
|
||||
setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
|
||||
}
|
||||
|
||||
notifyChange(newTree);
|
||||
notifyChangeWithDirty(newTree);
|
||||
},
|
||||
[addTargetParentId, treeData, notifyChange, cfg],
|
||||
[addTargetParentId, treeData, notifyChangeWithDirty, cfg],
|
||||
);
|
||||
|
||||
// 펼침/접기 토글
|
||||
|
|
@ -692,6 +993,101 @@ export function BomItemEditorComponent({
|
|||
});
|
||||
}, []);
|
||||
|
||||
// ─── 드래그 재정렬 ───
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
|
||||
// 트리에서 노드를 제거하고 반환
|
||||
const removeNode = (nodes: BomItemNode[], tempId: string): { tree: BomItemNode[]; removed: BomItemNode | null } => {
|
||||
const result: BomItemNode[] = [];
|
||||
let removed: BomItemNode | null = null;
|
||||
for (const node of nodes) {
|
||||
if (node.tempId === tempId) {
|
||||
removed = node;
|
||||
} else {
|
||||
const childResult = removeNode(node.children, tempId);
|
||||
if (childResult.removed) removed = childResult.removed;
|
||||
result.push({ ...node, children: childResult.tree });
|
||||
}
|
||||
}
|
||||
return { tree: result, removed };
|
||||
};
|
||||
|
||||
// 노드가 대상의 자손인지 확인 (자기 자신의 하위로 드래그 방지)
|
||||
const isDescendant = (nodes: BomItemNode[], parentId: string, childId: string): boolean => {
|
||||
const find = (list: BomItemNode[]): BomItemNode | null => {
|
||||
for (const n of list) {
|
||||
if (n.tempId === parentId) return n;
|
||||
const found = find(n.children);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const parent = find(nodes);
|
||||
if (!parent) return false;
|
||||
const check = (children: BomItemNode[]): boolean => {
|
||||
for (const c of children) {
|
||||
if (c.tempId === childId) return true;
|
||||
if (check(c.children)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return check(parent.children);
|
||||
};
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, tempId: string) => {
|
||||
setDragId(tempId);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", tempId);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, tempId: string) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverId(tempId);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetTempId: string) => {
|
||||
e.preventDefault();
|
||||
setDragOverId(null);
|
||||
if (!dragId || dragId === targetTempId) return;
|
||||
|
||||
// 자기 자신의 하위로 드래그 방지
|
||||
if (isDescendant(treeData, dragId, targetTempId)) return;
|
||||
|
||||
const { tree: treeWithout, removed } = removeNode(treeData, dragId);
|
||||
if (!removed) return;
|
||||
|
||||
// 대상 노드 바로 뒤에 같은 레벨로 삽입
|
||||
const insertAfter = (nodes: BomItemNode[], afterId: string, node: BomItemNode): { result: BomItemNode[]; inserted: boolean } => {
|
||||
const result: BomItemNode[] = [];
|
||||
let inserted = false;
|
||||
for (const n of nodes) {
|
||||
result.push(n);
|
||||
if (n.tempId === afterId) {
|
||||
result.push({ ...node, level: n.level, parent_detail_id: n.parent_detail_id });
|
||||
inserted = true;
|
||||
} else if (!inserted) {
|
||||
const childResult = insertAfter(n.children, afterId, node);
|
||||
if (childResult.inserted) {
|
||||
result[result.length - 1] = { ...n, children: childResult.result };
|
||||
inserted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { result, inserted };
|
||||
};
|
||||
|
||||
const { result, inserted } = insertAfter(treeWithout, targetTempId, removed);
|
||||
if (inserted) {
|
||||
const reindex = (nodes: BomItemNode[], depth = 0): BomItemNode[] =>
|
||||
nodes.map((n, i) => ({ ...n, seq_no: i + 1, level: depth, children: reindex(n.children, depth + 1) }));
|
||||
notifyChangeWithDirty(reindex(result));
|
||||
}
|
||||
|
||||
setDragId(null);
|
||||
}, [dragId, treeData, notifyChangeWithDirty]);
|
||||
|
||||
// ─── 재귀 렌더링 ───
|
||||
|
||||
const renderNodes = (nodes: BomItemNode[], depth: number) => {
|
||||
|
|
@ -711,6 +1107,10 @@ export function BomItemEditorComponent({
|
|||
onFieldChange={handleFieldChange}
|
||||
onDelete={handleDelete}
|
||||
onAddChild={handleAddChild}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
isDragOver={dragOverId === node.tempId}
|
||||
/>
|
||||
{isExpanded &&
|
||||
node.children.length > 0 &&
|
||||
|
|
@ -886,19 +1286,33 @@ export function BomItemEditorComponent({
|
|||
<div className="space-y-3">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">하위 품목 구성</h4>
|
||||
<Button
|
||||
onClick={handleAddRoot}
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
품목추가
|
||||
</Button>
|
||||
<h4 className="text-sm font-semibold">
|
||||
하위 품목 구성
|
||||
{hasChanges && <span className="ml-1.5 text-[10px] text-amber-500">(미저장)</span>}
|
||||
</h4>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
onClick={handleAddRoot}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
품목추가
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
disabled={saving || !hasChanges}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{saving ? "저장중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 트리 목록 */}
|
||||
<div className="space-y-1">
|
||||
<div className="max-h-[400px] space-y-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface BomDetailEditModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
node: Record<string, any> | null;
|
||||
isRootNode?: boolean;
|
||||
tableName: string;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export function BomDetailEditModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
node,
|
||||
isRootNode = false,
|
||||
tableName,
|
||||
onSaved,
|
||||
}: BomDetailEditModalProps) {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (node && open) {
|
||||
if (isRootNode) {
|
||||
setFormData({
|
||||
base_qty: node.base_qty || "",
|
||||
unit: node.unit || "",
|
||||
remark: node.remark || "",
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
quantity: node.quantity || "",
|
||||
unit: node.unit || node.detail_unit || "",
|
||||
process_type: node.process_type || "",
|
||||
base_qty: node.base_qty || "",
|
||||
loss_rate: node.loss_rate || "",
|
||||
remark: node.remark || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [node, open, isRootNode]);
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!node) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const targetTable = isRootNode ? "bom" : tableName;
|
||||
const realId = isRootNode ? node.id?.replace("__root_", "") : node.id;
|
||||
await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData);
|
||||
onSaved?.();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("[BomDetailEdit] 저장 실패:", error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const itemCode = isRootNode
|
||||
? node.child_item_code || node.item_code || node.bom_number || "-"
|
||||
: node.child_item_code || "-";
|
||||
const itemName = isRootNode
|
||||
? node.child_item_name || node.item_name || "-"
|
||||
: node.child_item_name || "-";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{isRootNode ? "BOM 헤더 수정" : "품목 수정"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{isRootNode
|
||||
? "BOM 기본 정보를 수정합니다"
|
||||
: "선택한 품목의 BOM 구성 정보를 수정합니다"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">품목코드</Label>
|
||||
<Input value={itemCode} disabled className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">품목명</Label>
|
||||
<Input value={itemName} disabled className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
{isRootNode ? "기준수량" : "구성수량"} *
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={isRootNode ? formData.base_qty : formData.quantity}
|
||||
onChange={(e) => handleChange(isRootNode ? "base_qty" : "quantity", e.target.value)}
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">단위</Label>
|
||||
<Input
|
||||
value={formData.unit}
|
||||
onChange={(e) => handleChange("unit", e.target.value)}
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isRootNode && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">공정</Label>
|
||||
<Input
|
||||
value={formData.process_type}
|
||||
onChange={(e) => handleChange("process_type", e.target.value)}
|
||||
placeholder="예: 조립공정"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">로스율 (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.loss_rate}
|
||||
onChange={(e) => handleChange("loss_rate", e.target.value)}
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">규격</Label>
|
||||
<Input
|
||||
value={node.child_specification || node.specification || "-"}
|
||||
disabled
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">재질</Label>
|
||||
<Input
|
||||
value={node.child_material || node.material || "-"}
|
||||
disabled
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">메모</Label>
|
||||
<Textarea
|
||||
value={formData.remark}
|
||||
onChange={(e) => handleChange("remark", e.target.value)}
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
className="mt-1 min-h-[60px] text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface BomHistoryItem {
|
||||
id: string;
|
||||
revision: string;
|
||||
version: string;
|
||||
change_type: string;
|
||||
change_description: string;
|
||||
changed_by: string;
|
||||
changed_date: string;
|
||||
}
|
||||
|
||||
interface BomHistoryModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
bomId: string | null;
|
||||
tableName?: string;
|
||||
}
|
||||
|
||||
const CHANGE_TYPE_STYLE: Record<string, string> = {
|
||||
"등록": "bg-blue-50 text-blue-600 ring-blue-200",
|
||||
"수정": "bg-amber-50 text-amber-600 ring-amber-200",
|
||||
"추가": "bg-emerald-50 text-emerald-600 ring-emerald-200",
|
||||
"삭제": "bg-red-50 text-red-600 ring-red-200",
|
||||
};
|
||||
|
||||
export function BomHistoryModal({ open, onOpenChange, bomId, tableName = "bom_history" }: BomHistoryModalProps) {
|
||||
const [history, setHistory] = useState<BomHistoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && bomId) {
|
||||
loadHistory();
|
||||
}
|
||||
}, [open, bomId]);
|
||||
|
||||
const loadHistory = async () => {
|
||||
if (!bomId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get(`/bom/${bomId}/history`, { params: { tableName } });
|
||||
if (res.data?.success) {
|
||||
setHistory(res.data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BomHistory] 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return "-";
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[650px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">이력 관리</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
BOM 변경 이력을 확인합니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-gray-400">이력이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-gray-50">
|
||||
<tr className="border-b">
|
||||
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "50px" }}>차수</th>
|
||||
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "50px" }}>버전</th>
|
||||
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "70px" }}>변경구분</th>
|
||||
<th className="px-3 py-2.5 text-left text-[11px] font-semibold text-gray-500">변경내용</th>
|
||||
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "70px" }}>변경자</th>
|
||||
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "130px" }}>변경일시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((item, idx) => (
|
||||
<tr key={item.id} className={cn("border-b border-gray-100", idx % 2 === 0 ? "bg-white" : "bg-gray-50/30")}>
|
||||
<td className="px-3 py-2.5 text-center tabular-nums">{item.revision || "-"}</td>
|
||||
<td className="px-3 py-2.5 text-center tabular-nums">{item.version || "-"}</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
|
||||
CHANGE_TYPE_STYLE[item.change_type] || "bg-gray-50 text-gray-500 ring-gray-200",
|
||||
)}>
|
||||
{item.change_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-gray-700">{item.change_description || "-"}</td>
|
||||
<td className="px-3 py-2.5 text-center text-gray-600">{item.changed_by || "-"}</td>
|
||||
<td className="px-3 py-2.5 text-center text-gray-400">{formatDate(item.changed_date)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,253 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Plus, Trash2, Download, ShieldCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface BomVersion {
|
||||
id: string;
|
||||
version_name: string;
|
||||
revision: number;
|
||||
status: string;
|
||||
created_by: string;
|
||||
created_date: string;
|
||||
}
|
||||
|
||||
interface BomVersionModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
bomId: string | null;
|
||||
tableName?: string;
|
||||
detailTable?: string;
|
||||
onVersionLoaded?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, { label: string; className: string }> = {
|
||||
developing: { label: "개발중", className: "bg-red-50 text-red-600 ring-red-200" },
|
||||
active: { label: "사용", className: "bg-emerald-50 text-emerald-600 ring-emerald-200" },
|
||||
inactive: { label: "사용중지", className: "bg-gray-100 text-gray-500 ring-gray-200" },
|
||||
};
|
||||
|
||||
export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_version", detailTable = "bom_detail", onVersionLoaded }: BomVersionModalProps) {
|
||||
const [versions, setVersions] = useState<BomVersion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [actionId, setActionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && bomId) loadVersions();
|
||||
}, [open, bomId]);
|
||||
|
||||
const loadVersions = async () => {
|
||||
if (!bomId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get(`/bom/${bomId}/versions`, { params: { tableName } });
|
||||
if (res.data?.success) setVersions(res.data.data || []);
|
||||
} catch (error) {
|
||||
console.error("[BomVersion] 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateVersion = async () => {
|
||||
if (!bomId) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable });
|
||||
if (res.data?.success) loadVersions();
|
||||
} catch (error) {
|
||||
console.error("[BomVersion] 생성 실패:", error);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadVersion = async (versionId: string) => {
|
||||
if (!bomId) return;
|
||||
setActionId(versionId);
|
||||
try {
|
||||
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/load`, { tableName, detailTable });
|
||||
if (res.data?.success) {
|
||||
loadVersions();
|
||||
onVersionLoaded?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BomVersion] 불러오기 실패:", error);
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivateVersion = async (versionId: string) => {
|
||||
if (!bomId || !confirm("이 버전을 사용 확정하시겠습니까?\n기존 사용중 버전은 사용중지로 변경됩니다.")) return;
|
||||
setActionId(versionId);
|
||||
try {
|
||||
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/activate`, { tableName });
|
||||
if (res.data?.success) {
|
||||
loadVersions();
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BomVersion] 사용 확정 실패:", error);
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteVersion = async (versionId: string) => {
|
||||
if (!bomId || !confirm("이 버전을 삭제하시겠습니까?")) return;
|
||||
setActionId(versionId);
|
||||
try {
|
||||
const res = await apiClient.delete(`/bom/${bomId}/versions/${versionId}`, { params: { tableName } });
|
||||
if (res.data?.success) loadVersions();
|
||||
} catch (error) {
|
||||
console.error("[BomVersion] 삭제 실패:", error);
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return "-";
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatus = (status: string) => STATUS_STYLE[status] || STATUS_STYLE.inactive;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[550px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">버전 관리</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
BOM 버전을 관리합니다. 불러오기로 특정 버전을 복원할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] space-y-2 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-gray-400">생성된 버전이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
versions.map((ver) => {
|
||||
const st = getStatus(ver.status);
|
||||
const isActing = actionId === ver.id;
|
||||
return (
|
||||
<div
|
||||
key={ver.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border p-3 transition-colors",
|
||||
ver.status === "active" ? "border-emerald-200 bg-emerald-50/30" : "border-gray-200",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
Version {ver.version_name}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
|
||||
st.className,
|
||||
)}>
|
||||
{st.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex gap-3 text-[11px] text-gray-400">
|
||||
<span>차수: {ver.revision}</span>
|
||||
<span>등록일: {formatDate(ver.created_date)}</span>
|
||||
{ver.created_by && <span>등록자: {ver.created_by}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleLoadVersion(ver.id)}
|
||||
disabled={isActing}
|
||||
className="h-7 gap-1 px-2 text-[10px]"
|
||||
>
|
||||
{isActing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
||||
불러오기
|
||||
</Button>
|
||||
{ver.status === "active" ? (
|
||||
<span className="flex h-7 items-center rounded-md bg-emerald-50 px-2 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-200">
|
||||
사용중
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleActivateVersion(ver.id)}
|
||||
disabled={isActing}
|
||||
className="h-7 gap-1 px-2 text-[10px] border-emerald-300 text-emerald-600 hover:bg-emerald-50"
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
사용 확정
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteVersion(ver.id)}
|
||||
disabled={isActing}
|
||||
className="h-7 gap-1 px-2 text-[10px]"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
onClick={handleCreateVersion}
|
||||
disabled={creating}
|
||||
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{creating ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Plus className="mr-1 h-4 w-4" />}
|
||||
신규 버전 생성
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -724,17 +724,28 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
try {
|
||||
// 1. 소스 컴포넌트에서 데이터 가져오기
|
||||
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
||||
let sourceProvider: import("@/types/data-transfer").DataProvidable | undefined;
|
||||
|
||||
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
|
||||
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
|
||||
const isAutoSource =
|
||||
!dataTransferConfig.sourceComponentId || dataTransferConfig.sourceComponentId === "__auto__";
|
||||
|
||||
if (!isAutoSource) {
|
||||
sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
||||
}
|
||||
|
||||
// 자동 탐색 모드이거나, 지정된 소스를 찾지 못한 경우
|
||||
// 현재 마운트된 DataProvider 중에서 table-list를 자동 탐색
|
||||
if (!sourceProvider) {
|
||||
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
|
||||
console.log("🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...");
|
||||
if (!isAutoSource) {
|
||||
console.log(
|
||||
`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`,
|
||||
);
|
||||
}
|
||||
console.log("🔍 [ButtonPrimary] 현재 활성 DataProvider 자동 탐색...");
|
||||
|
||||
const allProviders = screenContext.getAllDataProviders();
|
||||
|
||||
// 테이블 리스트 우선 탐색
|
||||
// table-list 우선 탐색
|
||||
for (const [id, provider] of allProviders) {
|
||||
if (provider.componentType === "table-list") {
|
||||
sourceProvider = provider;
|
||||
|
|
@ -743,7 +754,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
|
||||
// table-list가 없으면 첫 번째 DataProvider 사용
|
||||
if (!sourceProvider && allProviders.size > 0) {
|
||||
const firstEntry = allProviders.entries().next().value;
|
||||
if (firstEntry) {
|
||||
|
|
@ -784,15 +795,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
const additionalValues = additionalProvider.getSelectedData();
|
||||
|
||||
if (additionalValues && additionalValues.length > 0) {
|
||||
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
|
||||
const firstValue = additionalValues[0];
|
||||
|
||||
// fieldName이 지정되어 있으면 그 필드만 추출
|
||||
if (additionalSource.fieldName) {
|
||||
additionalData[additionalSource.fieldName] =
|
||||
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
||||
} else {
|
||||
// fieldName이 없으면 전체 객체 병합
|
||||
additionalData = { ...additionalData, ...firstValue };
|
||||
}
|
||||
|
||||
|
|
@ -802,6 +810,25 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
value: additionalData[additionalSource.fieldName || "all"],
|
||||
});
|
||||
}
|
||||
} else if (formData) {
|
||||
// DataProvider로 등록되지 않은 컴포넌트(v2-select 등)는 formData에서 값을 가져옴
|
||||
const comp = allComponents?.find((c: any) => c.id === additionalSource.componentId);
|
||||
const columnName =
|
||||
comp?.columnName ||
|
||||
comp?.componentConfig?.columnName ||
|
||||
comp?.overrides?.columnName;
|
||||
|
||||
if (columnName && formData[columnName] !== undefined && formData[columnName] !== "") {
|
||||
const targetField = additionalSource.fieldName || columnName;
|
||||
additionalData[targetField] = formData[columnName];
|
||||
|
||||
console.log("📦 추가 데이터 수집 (formData 폴백):", {
|
||||
sourceId: additionalSource.componentId,
|
||||
columnName,
|
||||
targetField,
|
||||
value: formData[columnName],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -870,44 +897,126 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 4. 매핑 규칙 적용 + 추가 데이터 병합
|
||||
const mappedData = sourceData.map((row) => {
|
||||
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
|
||||
// 4. 매핑 규칙 결정: 멀티 테이블 매핑 또는 레거시 단일 매핑
|
||||
let effectiveMappingRules: any[] = dataTransferConfig.mappingRules || [];
|
||||
|
||||
const sourceTableName = sourceProvider?.tableName;
|
||||
const multiTableMappings: Array<{ sourceTable: string; mappingRules: any[] }> =
|
||||
dataTransferConfig.multiTableMappings || [];
|
||||
|
||||
if (multiTableMappings.length > 0 && sourceTableName) {
|
||||
const matchedGroup = multiTableMappings.find((g) => g.sourceTable === sourceTableName);
|
||||
if (matchedGroup) {
|
||||
effectiveMappingRules = matchedGroup.mappingRules || [];
|
||||
console.log(`✅ [ButtonPrimary] 멀티 테이블 매핑 적용: ${sourceTableName}`, effectiveMappingRules);
|
||||
} else {
|
||||
console.log(`⚠️ [ButtonPrimary] 소스 테이블 ${sourceTableName}에 대한 매핑 없음, 동일 필드명 자동 매핑`);
|
||||
effectiveMappingRules = [];
|
||||
}
|
||||
} else if (multiTableMappings.length > 0 && !sourceTableName) {
|
||||
console.log("⚠️ [ButtonPrimary] 소스 테이블 미감지, 첫 번째 매핑 그룹 사용");
|
||||
effectiveMappingRules = multiTableMappings[0]?.mappingRules || [];
|
||||
}
|
||||
|
||||
const mappedData = sourceData.map((row) => {
|
||||
const mappedRow = applyMappingRules(row, effectiveMappingRules);
|
||||
|
||||
// 추가 데이터를 모든 행에 포함
|
||||
return {
|
||||
...mappedRow,
|
||||
...additionalData,
|
||||
};
|
||||
});
|
||||
|
||||
// 5. targetType / targetComponentId 기본값 및 자동 탐색
|
||||
const effectiveTargetType = dataTransferConfig.targetType || "component";
|
||||
let effectiveTargetComponentId = dataTransferConfig.targetComponentId;
|
||||
|
||||
// targetComponentId가 없으면 현재 화면에서 DataReceiver 자동 탐색
|
||||
if (effectiveTargetType === "component" && !effectiveTargetComponentId) {
|
||||
console.log("🔍 [ButtonPrimary] 타겟 컴포넌트 자동 탐색...");
|
||||
const allReceivers = screenContext.getAllDataReceivers();
|
||||
|
||||
// repeater 계열 우선 탐색
|
||||
for (const [id, receiver] of allReceivers) {
|
||||
if (
|
||||
receiver.componentType === "repeater-field-group" ||
|
||||
receiver.componentType === "v2-repeater" ||
|
||||
receiver.componentType === "repeater"
|
||||
) {
|
||||
effectiveTargetComponentId = id;
|
||||
console.log(`✅ [ButtonPrimary] 리피터 자동 발견: ${id} (${receiver.componentType})`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// repeater가 없으면 소스가 아닌 첫 번째 DataReceiver 사용
|
||||
if (!effectiveTargetComponentId) {
|
||||
for (const [id, receiver] of allReceivers) {
|
||||
if (receiver.componentType === "table-list" || receiver.componentType === "data-table") {
|
||||
effectiveTargetComponentId = id;
|
||||
console.log(`✅ [ButtonPrimary] DataReceiver 자동 발견: ${id} (${receiver.componentType})`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!effectiveTargetComponentId) {
|
||||
toast.error("데이터를 받을 수 있는 타겟 컴포넌트를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📦 데이터 전달:", {
|
||||
sourceData,
|
||||
mappedData,
|
||||
targetType: dataTransferConfig.targetType,
|
||||
targetComponentId: dataTransferConfig.targetComponentId,
|
||||
targetType: effectiveTargetType,
|
||||
targetComponentId: effectiveTargetComponentId,
|
||||
targetScreenId: dataTransferConfig.targetScreenId,
|
||||
});
|
||||
|
||||
// 5. 타겟으로 데이터 전달
|
||||
if (dataTransferConfig.targetType === "component") {
|
||||
// 같은 화면의 컴포넌트로 전달
|
||||
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
|
||||
// 6. 타겟으로 데이터 전달
|
||||
if (effectiveTargetType === "component") {
|
||||
const targetReceiver = screenContext.getDataReceiver(effectiveTargetComponentId);
|
||||
|
||||
const receiverConfig = {
|
||||
targetComponentId: effectiveTargetComponentId,
|
||||
targetComponentType: targetReceiver?.componentType || ("table" as const),
|
||||
mode: dataTransferConfig.mode || ("append" as const),
|
||||
mappingRules: dataTransferConfig.mappingRules || [],
|
||||
};
|
||||
|
||||
if (!targetReceiver) {
|
||||
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
|
||||
// 타겟이 아직 마운트되지 않은 경우 (조건부 레이어 등)
|
||||
// 버퍼에 저장하고 레이어 활성화 요청
|
||||
console.log(
|
||||
`⏳ [ButtonPrimary] 타겟 컴포넌트 미마운트, 대기열에 추가: ${effectiveTargetComponentId}`,
|
||||
);
|
||||
|
||||
screenContext.addPendingTransfer({
|
||||
targetComponentId: effectiveTargetComponentId,
|
||||
data: mappedData,
|
||||
config: receiverConfig,
|
||||
timestamp: Date.now(),
|
||||
targetLayerId: dataTransferConfig.targetLayerId,
|
||||
});
|
||||
|
||||
// 레이어 활성화 이벤트 발행 (page.tsx에서 수신)
|
||||
const activateEvent = new CustomEvent("activateLayerForComponent", {
|
||||
detail: {
|
||||
componentId: effectiveTargetComponentId,
|
||||
targetLayerId: dataTransferConfig.targetLayerId,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(activateEvent);
|
||||
|
||||
toast.info(`타겟 레이어를 활성화하고 데이터 전달을 준비합니다...`);
|
||||
return;
|
||||
}
|
||||
|
||||
await targetReceiver.receiveData(mappedData, {
|
||||
targetComponentId: dataTransferConfig.targetComponentId,
|
||||
targetComponentType: targetReceiver.componentType,
|
||||
mode: dataTransferConfig.mode || "append",
|
||||
mappingRules: dataTransferConfig.mappingRules || [],
|
||||
});
|
||||
await targetReceiver.receiveData(mappedData, receiverConfig);
|
||||
|
||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||
} else if (dataTransferConfig.targetType === "splitPanel") {
|
||||
} else if (effectiveTargetType === "splitPanel") {
|
||||
// 🆕 분할 패널의 반대편 화면으로 전달
|
||||
if (!splitPanelContext) {
|
||||
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Search, Plus, Trash2, Edit, ListOrdered, Package } from "lucide-react";
|
||||
import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -51,6 +51,8 @@ export function ItemRoutingComponent({
|
|||
refreshDetails,
|
||||
deleteDetail,
|
||||
deleteVersion,
|
||||
setDefaultVersion,
|
||||
unsetDefaultVersion,
|
||||
} = useItemRouting(configProp || {});
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
|
@ -70,16 +72,21 @@ export function ItemRoutingComponent({
|
|||
}, [fetchItems]);
|
||||
|
||||
// 모달 저장 성공 감지 -> 데이터 새로고침
|
||||
const refreshVersionsRef = React.useRef(refreshVersions);
|
||||
const refreshDetailsRef = React.useRef(refreshDetails);
|
||||
refreshVersionsRef.current = refreshVersions;
|
||||
refreshDetailsRef.current = refreshDetails;
|
||||
|
||||
useEffect(() => {
|
||||
const handleSaveSuccess = () => {
|
||||
refreshVersions();
|
||||
refreshDetails();
|
||||
refreshVersionsRef.current();
|
||||
refreshDetailsRef.current();
|
||||
};
|
||||
window.addEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||
return () => {
|
||||
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||
};
|
||||
}, [refreshVersions, refreshDetails]);
|
||||
}, []);
|
||||
|
||||
// 품목 검색
|
||||
const handleSearch = useCallback(() => {
|
||||
|
|
@ -156,6 +163,24 @@ export function ItemRoutingComponent({
|
|||
[config]
|
||||
);
|
||||
|
||||
// 기본 버전 토글
|
||||
const handleToggleDefault = useCallback(
|
||||
async (versionId: string, currentIsDefault: boolean) => {
|
||||
let success: boolean;
|
||||
if (currentIsDefault) {
|
||||
success = await unsetDefaultVersion(versionId);
|
||||
if (success) toast({ title: "기본 버전이 해제되었습니다" });
|
||||
} else {
|
||||
success = await setDefaultVersion(versionId);
|
||||
if (success) toast({ title: "기본 버전으로 설정되었습니다" });
|
||||
}
|
||||
if (!success) {
|
||||
toast({ title: "기본 버전 변경 실패", variant: "destructive" });
|
||||
}
|
||||
},
|
||||
[setDefaultVersion, unsetDefaultVersion, toast]
|
||||
);
|
||||
|
||||
// 삭제 확인
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
|
@ -175,12 +200,6 @@ export function ItemRoutingComponent({
|
|||
setDeleteTarget(null);
|
||||
}, [deleteTarget, deleteVersion, deleteDetail, toast]);
|
||||
|
||||
// entity join으로 가져온 공정명 컬럼 이름 추정
|
||||
const processNameKey = useMemo(() => {
|
||||
const ds = config.dataSource;
|
||||
return `${ds.processTable}_${ds.processNameColumn}`;
|
||||
}, [config.dataSource]);
|
||||
|
||||
const splitRatio = config.splitRatio || 40;
|
||||
|
||||
if (isPreview) {
|
||||
|
|
@ -295,34 +314,56 @@ export function ItemRoutingComponent({
|
|||
<span className="mr-1 text-xs text-muted-foreground">버전:</span>
|
||||
{versions.map((ver) => {
|
||||
const isActive = selectedVersionId === ver.id;
|
||||
const isDefault = ver.is_default === true;
|
||||
return (
|
||||
<div key={ver.id} className="flex items-center gap-0.5">
|
||||
<Badge
|
||||
variant={isActive ? "default" : "outline"}
|
||||
className={cn(
|
||||
"cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
|
||||
isActive && "bg-primary text-primary-foreground"
|
||||
isActive && "bg-primary text-primary-foreground",
|
||||
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700"
|
||||
)}
|
||||
onClick={() => selectVersion(ver.id)}
|
||||
>
|
||||
{isDefault && <Star className="mr-1 h-3 w-3 fill-current" />}
|
||||
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
|
||||
</Badge>
|
||||
{!config.readonly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget({
|
||||
type: "version",
|
||||
id: ver.id,
|
||||
name: ver.version_name || ver.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-5 w-5",
|
||||
isDefault
|
||||
? "text-amber-500 hover:text-amber-600"
|
||||
: "text-muted-foreground hover:text-amber-500"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleDefault(ver.id, isDefault);
|
||||
}}
|
||||
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}
|
||||
>
|
||||
<Star className={cn("h-3 w-3", isDefault && "fill-current")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget({
|
||||
type: "version",
|
||||
id: ver.id,
|
||||
name: ver.version_name || ver.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -394,11 +435,11 @@ export function ItemRoutingComponent({
|
|||
<TableRow key={detail.id}>
|
||||
{config.processColumns.map((col) => {
|
||||
let cellValue = detail[col.name];
|
||||
if (
|
||||
col.name === "process_code" &&
|
||||
detail[processNameKey]
|
||||
) {
|
||||
cellValue = `${detail[col.name]} (${detail[processNameKey]})`;
|
||||
if (cellValue == null) {
|
||||
const aliasKey = Object.keys(detail).find(
|
||||
(k) => k.endsWith(`_${col.name}`)
|
||||
);
|
||||
if (aliasKey) cellValue = detail[aliasKey];
|
||||
}
|
||||
return (
|
||||
<TableCell
|
||||
|
|
|
|||
|
|
@ -94,27 +94,29 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
[configKey]
|
||||
);
|
||||
|
||||
// 공정 상세 목록 조회 (특정 버전의 공정들)
|
||||
// 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함
|
||||
const fetchDetails = useCallback(
|
||||
async (versionId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const ds = configRef.current.dataSource;
|
||||
const res = await apiClient.get("/table-data/entity-join-api/data-with-joins", {
|
||||
params: {
|
||||
tableName: ds.routingDetailTable,
|
||||
searchConditions: JSON.stringify({
|
||||
[ds.routingDetailFkColumn]: {
|
||||
value: versionId,
|
||||
operator: "equals",
|
||||
},
|
||||
}),
|
||||
sortColumn: "seq_no",
|
||||
sortDirection: "ASC",
|
||||
},
|
||||
const searchConditions = {
|
||||
[ds.routingDetailFkColumn]: { value: versionId, operator: "equals" },
|
||||
};
|
||||
const params = new URLSearchParams({
|
||||
page: "1",
|
||||
size: "1000",
|
||||
search: JSON.stringify(searchConditions),
|
||||
sortBy: "seq_no",
|
||||
sortOrder: "ASC",
|
||||
enableEntityJoin: "true",
|
||||
});
|
||||
const res = await apiClient.get(
|
||||
`/table-management/tables/${ds.routingDetailTable}/data-with-joins?${params}`
|
||||
);
|
||||
if (res.data?.success) {
|
||||
setDetails(res.data.data || []);
|
||||
const result = res.data.data;
|
||||
setDetails(Array.isArray(result) ? result : result?.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("공정 상세 조회 실패", err);
|
||||
|
|
@ -136,14 +138,17 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
|
||||
const versionList = await fetchVersions(itemCode);
|
||||
|
||||
// 첫번째 버전 자동 선택
|
||||
if (config.autoSelectFirstVersion && versionList.length > 0) {
|
||||
const firstVersion = versionList[0];
|
||||
setSelectedVersionId(firstVersion.id);
|
||||
await fetchDetails(firstVersion.id);
|
||||
if (versionList.length > 0) {
|
||||
// 기본 버전 우선, 없으면 첫번째 버전 선택
|
||||
const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default);
|
||||
const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null);
|
||||
if (targetVersion) {
|
||||
setSelectedVersionId(targetVersion.id);
|
||||
await fetchDetails(targetVersion.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchVersions, fetchDetails, config.autoSelectFirstVersion]
|
||||
[fetchVersions, fetchDetails]
|
||||
);
|
||||
|
||||
// 버전 선택
|
||||
|
|
@ -181,7 +186,8 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
try {
|
||||
const ds = configRef.current.dataSource;
|
||||
const res = await apiClient.delete(
|
||||
`/table-data/${ds.routingDetailTable}/${detailId}`
|
||||
`/table-management/tables/${ds.routingDetailTable}/delete`,
|
||||
{ data: [{ id: detailId }] }
|
||||
);
|
||||
if (res.data?.success) {
|
||||
await refreshDetails();
|
||||
|
|
@ -201,7 +207,8 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
try {
|
||||
const ds = configRef.current.dataSource;
|
||||
const res = await apiClient.delete(
|
||||
`/table-data/${ds.routingVersionTable}/${versionId}`
|
||||
`/table-management/tables/${ds.routingVersionTable}/delete`,
|
||||
{ data: [{ id: versionId }] }
|
||||
);
|
||||
if (res.data?.success) {
|
||||
if (selectedVersionId === versionId) {
|
||||
|
|
@ -219,6 +226,51 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
[selectedVersionId, refreshVersions]
|
||||
);
|
||||
|
||||
// 기본 버전 설정
|
||||
const setDefaultVersion = useCallback(
|
||||
async (versionId: string) => {
|
||||
try {
|
||||
const ds = configRef.current.dataSource;
|
||||
const res = await apiClient.put(`${API_BASE}/versions/${versionId}/set-default`, {
|
||||
routingVersionTable: ds.routingVersionTable,
|
||||
routingFkColumn: ds.routingVersionFkColumn,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
if (selectedItemCode) {
|
||||
await fetchVersions(selectedItemCode);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("기본 버전 설정 실패", err);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[selectedItemCode, fetchVersions]
|
||||
);
|
||||
|
||||
// 기본 버전 해제
|
||||
const unsetDefaultVersion = useCallback(
|
||||
async (versionId: string) => {
|
||||
try {
|
||||
const ds = configRef.current.dataSource;
|
||||
const res = await apiClient.put(`${API_BASE}/versions/${versionId}/unset-default`, {
|
||||
routingVersionTable: ds.routingVersionTable,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
if (selectedItemCode) {
|
||||
await fetchVersions(selectedItemCode);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("기본 버전 해제 실패", err);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[selectedItemCode, fetchVersions]
|
||||
);
|
||||
|
||||
return {
|
||||
config,
|
||||
items,
|
||||
|
|
@ -235,5 +287,7 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
|||
refreshDetails,
|
||||
deleteDetail,
|
||||
deleteVersion,
|
||||
setDefaultVersion,
|
||||
unsetDefaultVersion,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export interface ItemData {
|
|||
export interface RoutingVersionData {
|
||||
id: string;
|
||||
version_name: string;
|
||||
is_default?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,445 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { WorkItemDetail, DetailTypeDefinition, InspectionStandard } from "../types";
|
||||
import { InspectionStandardLookup } from "./InspectionStandardLookup";
|
||||
|
||||
interface DetailFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: Partial<WorkItemDetail>) => void;
|
||||
detailTypes: DetailTypeDefinition[];
|
||||
editData?: WorkItemDetail | null;
|
||||
mode: "add" | "edit";
|
||||
}
|
||||
|
||||
const LOOKUP_TARGETS = [
|
||||
{ value: "equipment", label: "설비정보" },
|
||||
{ value: "material", label: "자재정보" },
|
||||
{ value: "worker", label: "작업자정보" },
|
||||
{ value: "tool", label: "공구정보" },
|
||||
{ value: "document", label: "문서정보" },
|
||||
];
|
||||
|
||||
const INPUT_TYPES = [
|
||||
{ value: "text", label: "텍스트" },
|
||||
{ value: "number", label: "숫자" },
|
||||
{ value: "date", label: "날짜" },
|
||||
{ value: "textarea", label: "장문텍스트" },
|
||||
{ value: "select", label: "선택형" },
|
||||
];
|
||||
|
||||
export function DetailFormModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
detailTypes,
|
||||
editData,
|
||||
mode,
|
||||
}: DetailFormModalProps) {
|
||||
const [formData, setFormData] = useState<Partial<WorkItemDetail>>({});
|
||||
const [inspectionLookupOpen, setInspectionLookupOpen] = useState(false);
|
||||
const [selectedInspection, setSelectedInspection] = useState<InspectionStandard | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (mode === "edit" && editData) {
|
||||
setFormData({ ...editData });
|
||||
if (editData.inspection_code) {
|
||||
setSelectedInspection({
|
||||
id: "",
|
||||
inspection_code: editData.inspection_code,
|
||||
inspection_item: editData.content || "",
|
||||
inspection_method: editData.inspection_method || "",
|
||||
unit: editData.unit || "",
|
||||
lower_limit: editData.lower_limit || "",
|
||||
upper_limit: editData.upper_limit || "",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setFormData({
|
||||
detail_type: detailTypes[0]?.value || "",
|
||||
content: "",
|
||||
is_required: "Y",
|
||||
});
|
||||
setSelectedInspection(null);
|
||||
}
|
||||
}
|
||||
}, [open, mode, editData, detailTypes]);
|
||||
|
||||
const updateField = (field: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleInspectionSelect = (item: InspectionStandard) => {
|
||||
setSelectedInspection(item);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
inspection_code: item.inspection_code,
|
||||
content: item.inspection_item,
|
||||
inspection_method: item.inspection_method,
|
||||
unit: item.unit,
|
||||
lower_limit: item.lower_limit || "",
|
||||
upper_limit: item.upper_limit || "",
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.detail_type) return;
|
||||
|
||||
const type = formData.detail_type;
|
||||
|
||||
if (type === "check" && !formData.content?.trim()) return;
|
||||
if (type === "inspect" && !formData.content?.trim()) return;
|
||||
if (type === "procedure" && !formData.content?.trim()) return;
|
||||
if (type === "input" && !formData.content?.trim()) return;
|
||||
if (type === "info" && !formData.lookup_target) return;
|
||||
|
||||
onSubmit(formData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const currentType = formData.detail_type || "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
상세 항목 {mode === "add" ? "추가" : "수정"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
상세 항목의 유형을 선택하고 내용을 입력하세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 유형 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
유형 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={currentType}
|
||||
onValueChange={(v) => {
|
||||
updateField("detail_type", v);
|
||||
setSelectedInspection(null);
|
||||
setFormData((prev) => ({
|
||||
detail_type: v,
|
||||
is_required: prev.is_required || "Y",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{detailTypes.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 체크리스트 */}
|
||||
{currentType === "check" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
체크 내용 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", e.target.value)}
|
||||
placeholder="예: 전원 상태 확인"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 검사항목 */}
|
||||
{currentType === "inspect" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
검사기준 선택 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Select value="_placeholder" disabled>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue>
|
||||
{selectedInspection
|
||||
? `${selectedInspection.inspection_code} - ${selectedInspection.inspection_item}`
|
||||
: "검사기준을 선택하세요"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_placeholder">선택</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-8 shrink-0 gap-1 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={() => setInspectionLookupOpen(true)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedInspection && (
|
||||
<div className="rounded border bg-muted/30 p-3">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
선택된 검사기준 정보
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<p>
|
||||
<strong>검사코드:</strong> {selectedInspection.inspection_code}
|
||||
</p>
|
||||
<p>
|
||||
<strong>검사항목:</strong> {selectedInspection.inspection_item}
|
||||
</p>
|
||||
<p>
|
||||
<strong>검사방법:</strong> {selectedInspection.inspection_method || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>단위:</strong> {selectedInspection.unit || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>하한값:</strong> {selectedInspection.lower_limit || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>상한값:</strong> {selectedInspection.upper_limit || "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
검사 항목명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", e.target.value)}
|
||||
placeholder="예: 외경 치수"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">검사 방법</Label>
|
||||
<Input
|
||||
value={formData.inspection_method || ""}
|
||||
onChange={(e) => updateField("inspection_method", e.target.value)}
|
||||
placeholder="예: 마이크로미터"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">단위</Label>
|
||||
<Input
|
||||
value={formData.unit || ""}
|
||||
onChange={(e) => updateField("unit", e.target.value)}
|
||||
placeholder="예: mm"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">하한값</Label>
|
||||
<Input
|
||||
value={formData.lower_limit || ""}
|
||||
onChange={(e) => updateField("lower_limit", e.target.value)}
|
||||
placeholder="예: 7.95"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">상한값</Label>
|
||||
<Input
|
||||
value={formData.upper_limit || ""}
|
||||
onChange={(e) => updateField("upper_limit", e.target.value)}
|
||||
placeholder="예: 8.05"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 작업절차 */}
|
||||
{currentType === "procedure" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
작업 내용 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", e.target.value)}
|
||||
placeholder="예: 자재 투입"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">소요 시간 (분)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.duration_minutes ?? ""}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
"duration_minutes",
|
||||
e.target.value ? Number(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
placeholder="예: 5"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 직접입력 */}
|
||||
{currentType === "input" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
입력 항목명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => updateField("content", e.target.value)}
|
||||
placeholder="예: 작업자 의견"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">입력 타입</Label>
|
||||
<Select
|
||||
value={formData.input_type || "text"}
|
||||
onValueChange={(v) => updateField("input_type", v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INPUT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 정보조회 */}
|
||||
{currentType === "info" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
조회 대상 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.lookup_target || ""}
|
||||
onValueChange={(v) => updateField("lookup_target", v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOOKUP_TARGETS.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">표시 항목</Label>
|
||||
<Input
|
||||
value={formData.display_fields || ""}
|
||||
onChange={(e) => updateField("display_fields", e.target.value)}
|
||||
placeholder="예: 설비명, 설비코드"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필수 여부 (모든 유형 공통) */}
|
||||
{currentType && (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">필수 여부</Label>
|
||||
<Select
|
||||
value={formData.is_required || "Y"}
|
||||
onValueChange={(v) => updateField("is_required", v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">필수</SelectItem>
|
||||
<SelectItem value="N">선택</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{mode === "add" ? "추가" : "수정"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<InspectionStandardLookup
|
||||
open={inspectionLookupOpen}
|
||||
onClose={() => setInspectionLookupOpen(false)}
|
||||
onSelect={handleInspectionSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { InspectionStandard } from "../types";
|
||||
|
||||
interface InspectionStandardLookupProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (item: InspectionStandard) => void;
|
||||
}
|
||||
|
||||
export function InspectionStandardLookup({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: InspectionStandardLookupProps) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const search: Record<string, any> = {};
|
||||
if (searchText.trim()) {
|
||||
search.inspection_item = searchText.trim();
|
||||
search.inspection_code = searchText.trim();
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
page: "1",
|
||||
size: "100",
|
||||
enableEntityJoin: "true",
|
||||
...(searchText.trim() ? { search: JSON.stringify(search) } : {}),
|
||||
});
|
||||
const res = await apiClient.get(
|
||||
`/table-management/tables/inspection_standard/data-with-joins?${params}`
|
||||
);
|
||||
if (res.data?.success) {
|
||||
const result = res.data.data;
|
||||
setData(Array.isArray(result) ? result : result?.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("검사기준 조회 실패", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchData();
|
||||
}
|
||||
}, [open, fetchData]);
|
||||
|
||||
const handleSelect = (item: any) => {
|
||||
onSelect({
|
||||
id: item.id,
|
||||
inspection_code: item.inspection_code || "",
|
||||
inspection_item: item.inspection_item || item.inspection_criteria || "",
|
||||
inspection_method: item.inspection_method || "",
|
||||
unit: item.unit || "",
|
||||
lower_limit: item.lower_limit || "",
|
||||
upper_limit: item.upper_limit || "",
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<Search className="h-5 w-5" />
|
||||
검사기준 조회
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
검사기준을 검색하여 선택하세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="검사항목명 또는 검사코드로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && fetchData()}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] overflow-auto rounded border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr className="border-b">
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||
검사코드
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||
검사항목
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||
검사방법
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
|
||||
하한
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
|
||||
상한
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
|
||||
단위
|
||||
</th>
|
||||
<th className="w-16 px-3 py-2 text-center font-medium text-muted-foreground">
|
||||
선택
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-muted-foreground">
|
||||
조회 중...
|
||||
</td>
|
||||
</tr>
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-muted-foreground">
|
||||
검사기준이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((item, idx) => (
|
||||
<tr
|
||||
key={item.id || idx}
|
||||
className="border-b transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-3 py-2">{item.inspection_code || "-"}</td>
|
||||
<td className="px-3 py-2">
|
||||
{item.inspection_item || item.inspection_criteria || "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.inspection_method || "-"}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{item.lower_limit || "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{item.upper_limit || "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{item.unit || "-"}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ export function ItemProcessSelector({
|
|||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.item_code} className="mb-1">
|
||||
<div key={item.id} className="mb-1">
|
||||
{/* 품목 헤더 */}
|
||||
<button
|
||||
onClick={() => toggleItem(item.item_code, item.item_name)}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Plus, Pencil, Trash2, Check, X, HandMetal } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WorkItem, WorkItemDetail, DetailTypeDefinition } from "../types";
|
||||
import { DetailFormModal } from "./DetailFormModal";
|
||||
|
||||
interface WorkItemDetailListProps {
|
||||
workItem: WorkItem | null;
|
||||
|
|
@ -34,20 +27,13 @@ export function WorkItemDetailList({
|
|||
onUpdateDetail,
|
||||
onDeleteDetail,
|
||||
}: WorkItemDetailListProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editData, setEditData] = useState<Partial<WorkItemDetail>>({});
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newData, setNewData] = useState<Partial<WorkItemDetail>>({
|
||||
detail_type: detailTypes[0]?.value || "",
|
||||
content: "",
|
||||
is_required: "N",
|
||||
sort_order: 0,
|
||||
});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
|
||||
const [editTarget, setEditTarget] = useState<WorkItemDetail | null>(null);
|
||||
|
||||
if (!workItem) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<HandMetal className="mb-2 h-10 w-10 text-amber-400" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
왼쪽에서 항목을 선택하세요
|
||||
</p>
|
||||
|
|
@ -58,25 +44,60 @@ export function WorkItemDetailList({
|
|||
const getTypeLabel = (value?: string) =>
|
||||
detailTypes.find((t) => t.value === value)?.label || value || "-";
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newData.content?.trim()) return;
|
||||
onCreateDetail({
|
||||
...newData,
|
||||
sort_order: details.length + 1,
|
||||
});
|
||||
setNewData({
|
||||
detail_type: detailTypes[0]?.value || "",
|
||||
content: "",
|
||||
is_required: "N",
|
||||
sort_order: 0,
|
||||
});
|
||||
setIsAdding(false);
|
||||
const handleOpenAdd = () => {
|
||||
setModalMode("add");
|
||||
setEditTarget(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = (id: string) => {
|
||||
onUpdateDetail(id, editData);
|
||||
setEditingId(null);
|
||||
setEditData({});
|
||||
const handleOpenEdit = (detail: WorkItemDetail) => {
|
||||
setModalMode("edit");
|
||||
setEditTarget(detail);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (data: Partial<WorkItemDetail>) => {
|
||||
if (modalMode === "add") {
|
||||
onCreateDetail({ ...data, sort_order: details.length + 1 });
|
||||
} else if (editTarget) {
|
||||
onUpdateDetail(editTarget.id, data);
|
||||
}
|
||||
};
|
||||
|
||||
const getContentSummary = (detail: WorkItemDetail): string => {
|
||||
const type = detail.detail_type;
|
||||
if (type === "inspect" && detail.inspection_code) {
|
||||
const parts = [detail.content];
|
||||
if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`);
|
||||
if (detail.lower_limit || detail.upper_limit) {
|
||||
parts.push(`(${detail.lower_limit || "-"} ~ ${detail.upper_limit || "-"} ${detail.unit || ""})`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
if (type === "procedure" && detail.duration_minutes) {
|
||||
return `${detail.content} (${detail.duration_minutes}분)`;
|
||||
}
|
||||
if (type === "input" && detail.input_type) {
|
||||
const typeMap: Record<string, string> = {
|
||||
text: "텍스트",
|
||||
number: "숫자",
|
||||
date: "날짜",
|
||||
textarea: "장문",
|
||||
select: "선택형",
|
||||
};
|
||||
return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`;
|
||||
}
|
||||
if (type === "info" && detail.lookup_target) {
|
||||
const targetMap: Record<string, string> = {
|
||||
equipment: "설비정보",
|
||||
material: "자재정보",
|
||||
worker: "작업자정보",
|
||||
tool: "공구정보",
|
||||
document: "문서정보",
|
||||
};
|
||||
return `${targetMap[detail.lookup_target] || detail.lookup_target} 조회`;
|
||||
}
|
||||
return detail.content || "-";
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -94,7 +115,7 @@ export function WorkItemDetailList({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() => setIsAdding(true)}
|
||||
onClick={handleOpenAdd}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
상세 추가
|
||||
|
|
@ -132,242 +153,51 @@ export function WorkItemDetailList({
|
|||
key={detail.id}
|
||||
className="border-b transition-colors hover:bg-muted/30"
|
||||
>
|
||||
{editingId === detail.id ? (
|
||||
<>
|
||||
<td className="px-2 py-1.5 text-center">{idx + 1}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Select
|
||||
value={editData.detail_type || detail.detail_type || ""}
|
||||
onValueChange={(v) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
detail_type: v,
|
||||
}))
|
||||
}
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{getTypeLabel(detail.detail_type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">{getContentSummary(detail)}</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Badge
|
||||
variant={detail.is_required === "Y" ? "default" : "secondary"}
|
||||
className="text-[10px] font-normal"
|
||||
>
|
||||
{detail.is_required === "Y" ? "필수" : "선택"}
|
||||
</Badge>
|
||||
</td>
|
||||
{!readonly && (
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleOpenEdit(detail)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{detailTypes.map((t) => (
|
||||
<SelectItem
|
||||
key={t.value}
|
||||
value={t.value}
|
||||
className="text-xs"
|
||||
>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Input
|
||||
value={editData.content ?? detail.content}
|
||||
onChange={(e) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
content: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Select
|
||||
value={editData.is_required ?? detail.is_required}
|
||||
onValueChange={(v) =>
|
||||
setEditData((prev) => ({
|
||||
...prev,
|
||||
is_required: v,
|
||||
}))
|
||||
}
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => onDeleteDetail(detail.id)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-14 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y" className="text-xs">
|
||||
필수
|
||||
</SelectItem>
|
||||
<SelectItem value="N" className="text-xs">
|
||||
선택
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-green-600"
|
||||
onClick={() => handleSaveEdit(detail.id)}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setEditData({});
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-normal"
|
||||
>
|
||||
{getTypeLabel(detail.detail_type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">{detail.content}</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Badge
|
||||
variant={
|
||||
detail.is_required === "Y" ? "default" : "secondary"
|
||||
}
|
||||
className="text-[10px] font-normal"
|
||||
>
|
||||
{detail.is_required === "Y" ? "필수" : "선택"}
|
||||
</Badge>
|
||||
</td>
|
||||
{!readonly && (
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => {
|
||||
setEditingId(detail.id);
|
||||
setEditData({
|
||||
detail_type: detail.detail_type,
|
||||
content: detail.content,
|
||||
is_required: detail.is_required,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => onDeleteDetail(detail.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* 추가 행 */}
|
||||
{isAdding && (
|
||||
<tr className="border-b bg-primary/5">
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">
|
||||
{details.length + 1}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Select
|
||||
value={newData.detail_type || ""}
|
||||
onValueChange={(v) =>
|
||||
setNewData((prev) => ({ ...prev, detail_type: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{detailTypes.map((t) => (
|
||||
<SelectItem
|
||||
key={t.value}
|
||||
value={t.value}
|
||||
className="text-xs"
|
||||
>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="상세 내용 입력"
|
||||
value={newData.content || ""}
|
||||
onChange={(e) =>
|
||||
setNewData((prev) => ({
|
||||
...prev,
|
||||
content: e.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Select
|
||||
value={newData.is_required || "N"}
|
||||
onValueChange={(v) =>
|
||||
setNewData((prev) => ({ ...prev, is_required: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-14 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y" className="text-xs">
|
||||
필수
|
||||
</SelectItem>
|
||||
<SelectItem value="N" className="text-xs">
|
||||
선택
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-green-600"
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setIsAdding(false)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{details.length === 0 && !isAdding && (
|
||||
{details.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
상세 항목이 없습니다. "상세 추가" 버튼을 클릭하여 추가하세요.
|
||||
|
|
@ -375,6 +205,16 @@ export function WorkItemDetailList({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 추가/수정 모달 */}
|
||||
<DetailFormModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSubmit={handleSubmit}
|
||||
detailTypes={detailTypes}
|
||||
editData={editTarget}
|
||||
mode={modalMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ export const defaultConfig: ProcessWorkStandardConfig = {
|
|||
{ key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
|
||||
],
|
||||
detailTypes: [
|
||||
{ value: "CHECK", label: "체크" },
|
||||
{ value: "INSPECTION", label: "검사" },
|
||||
{ value: "MEASUREMENT", label: "측정" },
|
||||
{ value: "check", label: "체크리스트" },
|
||||
{ value: "inspect", label: "검사항목" },
|
||||
{ value: "procedure", label: "작업절차" },
|
||||
{ value: "input", label: "직접입력" },
|
||||
{ value: "info", label: "정보조회" },
|
||||
],
|
||||
splitRatio: 30,
|
||||
leftPanelTitle: "품목 및 공정 선택",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
SelectionState,
|
||||
} from "../types";
|
||||
|
||||
const API_BASE = "/api/process-work-standard";
|
||||
const API_BASE = "/process-work-standard";
|
||||
|
||||
export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
||||
const [items, setItems] = useState<ItemData[]>([]);
|
||||
|
|
|
|||
|
|
@ -87,6 +87,29 @@ export interface WorkItemDetail {
|
|||
sort_order: number;
|
||||
remark?: string;
|
||||
created_date?: string;
|
||||
// 검사항목 전용
|
||||
inspection_code?: string;
|
||||
inspection_method?: string;
|
||||
unit?: string;
|
||||
lower_limit?: string;
|
||||
upper_limit?: string;
|
||||
// 작업절차 전용
|
||||
duration_minutes?: number;
|
||||
// 직접입력 전용
|
||||
input_type?: string;
|
||||
// 정보조회 전용
|
||||
lookup_target?: string;
|
||||
display_fields?: string;
|
||||
}
|
||||
|
||||
export interface InspectionStandard {
|
||||
id: string;
|
||||
inspection_code: string;
|
||||
inspection_item: string;
|
||||
inspection_method: string;
|
||||
unit: string;
|
||||
lower_limit?: string;
|
||||
upper_limit?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
|||
return (
|
||||
<V2Repeater
|
||||
config={config}
|
||||
componentId={component?.id}
|
||||
parentId={resolvedParentId}
|
||||
data={Array.isArray(data) ? data : undefined}
|
||||
onDataChange={onDataChange}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
|||
static componentDefinition = V2SelectDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
|
||||
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, allComponents, ...restProps } = this.props as any;
|
||||
|
||||
// 컴포넌트 설정 추출
|
||||
const config = component.componentConfig || component.config || {};
|
||||
|
|
@ -107,8 +107,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
|||
// 디버깅 필요시 주석 해제
|
||||
// console.log("🔍 [V2SelectRenderer]", { componentId: component.id, effectiveStyle, effectiveSize });
|
||||
|
||||
// 🔧 restProps에서 style, size 제외 (effectiveStyle/effectiveSize가 우선되어야 함)
|
||||
const { style: _style, size: _size, ...restPropsClean } = restProps as any;
|
||||
const { style: _style, size: _size, allComponents: _allComp, ...restPropsClean } = restProps as any;
|
||||
|
||||
return (
|
||||
<V2Select
|
||||
|
|
@ -119,9 +118,10 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
|||
disabled={config.disabled || component.disabled}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
onFormDataChange={isInteractive ? onFormDataChange : undefined}
|
||||
allComponents={allComponents}
|
||||
config={{
|
||||
mode: config.mode || "dropdown",
|
||||
// 🔧 카테고리 타입이면 source를 무조건 "category"로 강제 (테이블 타입 관리 설정 우선)
|
||||
source: isCategoryType ? "category" : (config.source || "distinct"),
|
||||
multiple: config.multiple || false,
|
||||
searchable: config.searchable ?? true,
|
||||
|
|
@ -131,7 +131,6 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
|||
entityTable: config.entityTable,
|
||||
entityLabelColumn: config.entityLabelColumn,
|
||||
entityValueColumn: config.entityValueColumn,
|
||||
// 🔧 카테고리 소스 지원 (tableName, columnName 폴백)
|
||||
categoryTable: config.categoryTable || (isCategoryType ? tableName : undefined),
|
||||
categoryColumn: config.categoryColumn || (isCategoryType ? columnName : undefined),
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ import { dataApi } from "@/lib/api/data";
|
|||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { apiClient, getFullImageUrl } from "@/lib/api/client";
|
||||
import { getFilePreviewUrl } from "@/lib/api/file";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -51,6 +52,42 @@ export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
|||
selectedPanelComponentId?: string;
|
||||
}
|
||||
|
||||
// 이미지 셀 렌더링 컴포넌트 (objid 또는 파일 경로 지원)
|
||||
const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
||||
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!value) return;
|
||||
const strVal = String(value).trim();
|
||||
if (!strVal || strVal === "-") return;
|
||||
|
||||
if (strVal.startsWith("http") || strVal.startsWith("/uploads/") || strVal.startsWith("/api/")) {
|
||||
setImgSrc(getFullImageUrl(strVal));
|
||||
} else {
|
||||
const previewUrl = getFilePreviewUrl(strVal);
|
||||
fetch(previewUrl, { credentials: "include" })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("fetch failed");
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => setImgSrc(URL.createObjectURL(blob)))
|
||||
.catch(() => setImgSrc(null));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
if (!imgSrc) return <span className="text-muted-foreground text-xs">-</span>;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover"
|
||||
onError={() => setImgSrc(null)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SplitPanelCellImage.displayName = "SplitPanelCellImage";
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 컴포넌트
|
||||
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
||||
|
|
@ -210,6 +247,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
||||
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({});
|
||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||
|
||||
// 추가 탭 관련 상태
|
||||
|
|
@ -905,6 +943,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
) => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
// 이미지 타입 컬럼 처리
|
||||
const colInputType = columnInputTypes[columnName];
|
||||
if (colInputType === "image" && value) {
|
||||
return <SplitPanelCellImage value={String(value)} />;
|
||||
}
|
||||
|
||||
// 🆕 날짜 포맷 적용
|
||||
if (format?.type === "date" || format?.dateFormat) {
|
||||
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||
|
|
@ -971,7 +1015,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 일반 값
|
||||
return String(value);
|
||||
},
|
||||
[formatDateValue, formatNumberValue],
|
||||
[formatDateValue, formatNumberValue, columnInputTypes],
|
||||
);
|
||||
|
||||
// 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼
|
||||
|
|
@ -1835,14 +1879,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
});
|
||||
setRightColumnLabels(labels);
|
||||
console.log("✅ 우측 컬럼 라벨 로드:", labels);
|
||||
|
||||
// 컬럼 inputType 로드 (이미지 등 특수 렌더링을 위해)
|
||||
const tablesToLoad = new Set<string>([rightTableName]);
|
||||
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
|
||||
additionalTabs.forEach((tab: any) => {
|
||||
if (tab.tableName) tablesToLoad.add(tab.tableName);
|
||||
});
|
||||
|
||||
const inputTypes: Record<string, string> = {};
|
||||
for (const tbl of tablesToLoad) {
|
||||
try {
|
||||
const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl);
|
||||
inputTypesResponse.forEach((col: any) => {
|
||||
const colName = col.columnName || col.column_name;
|
||||
if (colName) {
|
||||
inputTypes[colName] = col.inputType || "text";
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setColumnInputTypes(inputTypes);
|
||||
} catch (error) {
|
||||
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadRightTableColumns();
|
||||
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
||||
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
|
||||
|
||||
// 좌측 테이블 카테고리 매핑 로드
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
|||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
const strValue = String(value);
|
||||
// 다중 이미지인 경우 대표 이미지(첫 번째)만 사용
|
||||
const rawValue = String(value);
|
||||
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
|
||||
const isObjid = /^\d+$/.test(strValue);
|
||||
|
||||
if (isObjid) {
|
||||
|
|
@ -89,8 +91,8 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
|||
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// objid인 경우 preview URL로 열기, 아니면 full URL로 열기
|
||||
const strValue = String(value);
|
||||
const rawValue = String(value);
|
||||
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
|
||||
const isObjid = /^\d+$/.test(strValue);
|
||||
const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
|
||||
window.open(openUrl, "_blank");
|
||||
|
|
@ -652,7 +654,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
|
||||
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
|
||||
const [columnMeta, setColumnMeta] = useState<
|
||||
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
|
||||
Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }>
|
||||
>({});
|
||||
// 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType)
|
||||
const [joinedColumnMeta, setJoinedColumnMeta] = useState<
|
||||
|
|
@ -863,6 +865,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const dataProvider: DataProvidable = {
|
||||
componentId: component.id,
|
||||
componentType: "table-list",
|
||||
tableName: tableConfig.selectedTable,
|
||||
|
||||
getSelectedData: () => {
|
||||
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
||||
|
|
@ -1015,23 +1018,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반
|
||||
// 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
|
||||
|
||||
if (response.data.success && response.data.data && response.data.data.length > 0) {
|
||||
return response.data.data.map((item: any) => ({
|
||||
value: String(item.value),
|
||||
label: String(item.label),
|
||||
}));
|
||||
}
|
||||
} catch (error: any) {
|
||||
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
|
||||
}
|
||||
|
||||
// fallback: 현재 로드된 데이터에서 고유 값 추출
|
||||
const isLabelType = ["category", "entity", "code"].includes(inputType);
|
||||
const labelField = isLabelType ? `${columnName}_name` : columnName;
|
||||
|
||||
// 현재 로드된 데이터에서 고유 값 추출
|
||||
const uniqueValuesMap = new Map<string, string>(); // value -> label
|
||||
const uniqueValuesMap = new Map<string, string>();
|
||||
|
||||
data.forEach((row) => {
|
||||
const value = row[columnName];
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
|
||||
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
|
||||
uniqueValuesMap.set(String(value), label);
|
||||
}
|
||||
});
|
||||
|
||||
// Map을 배열로 변환하고 라벨 기준으로 정렬
|
||||
const result = Array.from(uniqueValuesMap.entries())
|
||||
.map(([value, label]) => ({
|
||||
value: value,
|
||||
|
|
@ -1219,13 +1234,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const cached = tableColumnCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
|
||||
const labels: Record<string, string> = {};
|
||||
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
||||
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> = {};
|
||||
|
||||
// 캐시된 inputTypes 맵 생성
|
||||
const inputTypeMap: Record<string, string> = {};
|
||||
const categoryRefMap: Record<string, string> = {};
|
||||
if (cached.inputTypes) {
|
||||
cached.inputTypes.forEach((col: any) => {
|
||||
inputTypeMap[col.columnName] = col.inputType;
|
||||
if (col.categoryRef) {
|
||||
categoryRefMap[col.columnName] = col.categoryRef;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1234,7 +1252,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
meta[col.columnName] = {
|
||||
webType: col.webType,
|
||||
codeCategory: col.codeCategory,
|
||||
inputType: inputTypeMap[col.columnName], // 캐시된 inputType 사용!
|
||||
inputType: inputTypeMap[col.columnName],
|
||||
categoryRef: categoryRefMap[col.columnName],
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -1245,11 +1264,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
|
||||
|
||||
// 컬럼 입력 타입 정보 가져오기
|
||||
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
|
||||
const inputTypeMap: Record<string, string> = {};
|
||||
const categoryRefMap: Record<string, string> = {};
|
||||
inputTypes.forEach((col: any) => {
|
||||
inputTypeMap[col.columnName] = col.inputType;
|
||||
if (col.categoryRef) {
|
||||
categoryRefMap[col.columnName] = col.categoryRef;
|
||||
}
|
||||
});
|
||||
|
||||
tableColumnCache.set(cacheKey, {
|
||||
|
|
@ -1259,7 +1281,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
|
||||
const labels: Record<string, string> = {};
|
||||
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
||||
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> = {};
|
||||
|
||||
columns.forEach((col: any) => {
|
||||
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
||||
|
|
@ -1267,6 +1289,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
webType: col.webType,
|
||||
codeCategory: col.codeCategory,
|
||||
inputType: inputTypeMap[col.columnName],
|
||||
categoryRef: categoryRefMap[col.columnName],
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -1341,14 +1364,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
for (const columnName of categoryColumns) {
|
||||
try {
|
||||
// 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인
|
||||
let targetTable = tableConfig.selectedTable;
|
||||
let targetColumn = columnName;
|
||||
|
||||
if (columnName.includes(".")) {
|
||||
// category_ref가 있으면 참조 테이블.컬럼 기준으로 조회
|
||||
const meta = columnMeta[columnName];
|
||||
if (meta?.categoryRef) {
|
||||
const refParts = meta.categoryRef.split(".");
|
||||
if (refParts.length === 2) {
|
||||
targetTable = refParts[0];
|
||||
targetColumn = refParts[1];
|
||||
}
|
||||
} else if (columnName.includes(".")) {
|
||||
// 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태
|
||||
const parts = columnName.split(".");
|
||||
targetTable = parts[0]; // 조인된 테이블명 (예: item_info)
|
||||
targetColumn = parts[1]; // 실제 컬럼명 (예: material)
|
||||
targetTable = parts[0];
|
||||
targetColumn = parts[1];
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
|
||||
|
|
@ -1549,7 +1580,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
categoryColumns.length,
|
||||
JSON.stringify(categoryColumns),
|
||||
JSON.stringify(tableConfig.columns),
|
||||
]); // 더 명확한 의존성
|
||||
columnMeta,
|
||||
]);
|
||||
|
||||
// ========================================
|
||||
// 데이터 가져오기
|
||||
|
|
@ -4255,7 +4287,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 다중 값인 경우: 여러 배지 렌더링
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="flex flex-nowrap gap-1 overflow-hidden">
|
||||
{values.map((val, idx) => {
|
||||
const categoryData = mapping?.[val];
|
||||
const displayLabel = categoryData?.label || val;
|
||||
|
|
@ -4264,7 +4296,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||||
if (!displayColor || displayColor === "none" || !categoryData) {
|
||||
return (
|
||||
<span key={idx} className="text-sm">
|
||||
<span key={idx} className="shrink-0 whitespace-nowrap text-sm">
|
||||
{displayLabel}
|
||||
{idx < values.length - 1 && ", "}
|
||||
</span>
|
||||
|
|
@ -4278,7 +4310,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
backgroundColor: displayColor,
|
||||
borderColor: displayColor,
|
||||
}}
|
||||
className="text-white"
|
||||
className="shrink-0 whitespace-nowrap text-white"
|
||||
>
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue