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

This commit is contained in:
syc0123 2026-02-27 14:26:16 +09:00
commit a0e3147b47
52 changed files with 6290 additions and 2292 deletions

1
.gitignore vendored
View File

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

733
PLAN.MD
View File

@ -1,404 +1,202 @@
# 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성 # 현재 구현 계획: pop-card-list 입력 필드/계산 필드 구조 개편
> **작성일**: 2026-02-10 > **작성일**: 2026-02-24
> **상태**: 코딩 완료 (방어 로직 패치 포함) > **상태**: 계획 완료, 코딩 대기
> **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성 > **목적**: 입력 필드 설정 단순화 + 본문 필드에 계산식 통합 + 기존 계산 필드 섹션 제거
--- ---
## 1. 문제 요약 ## 1. 변경 개요
pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가. ### 배경
- 기존: "입력 필드", "계산 필드", "담기 버튼" 3개가 별도 섹션으로 분리
- 문제: 계산 필드가 본문 필드와 동일한 위치에 표시되어야 하는데 별도 영역에 있음
- 문제: 입력 필드의 min/max 고정값은 비실용적 (실제로는 DB 컬럼 기준 제한이 필요)
- 문제: step, columnName, sourceColumns, resultColumn 등 죽은 코드 존재
| # | 문제 | 심각도 | 영향 | ### 목표
|---|------|--------|------| 1. **본문 필드에 계산식 지원 추가** - 필드별로 "DB 컬럼" 또는 "계산식" 선택
| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 | 2. **입력 필드 설정 단순화** - 고정 min/max 제거, 제한 기준 컬럼 방식으로 변경
| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 | 3. **기존 "계산 필드" 섹션 제거** - 본문 필드에 통합되므로 불필요
| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 | 4. **죽은 코드 정리**
| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 |
--- ---
## 2. 수정 대상 파일 (2개) ## 2. 수정 대상 파일 (3개)
### 파일 A: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx` ### 파일 A: `frontend/lib/registry/pop-components/types.ts`
**변경 유형**: 설정 UI 추가 3건 #### 변경 A-1: CardFieldBinding 타입 확장
#### 변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래) **현재 코드** (라인 367~372):
```typescript
집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가. export interface CardFieldBinding {
id: string;
**추가할 위치**: `{/* 집계 함수 + 대상 컬럼 */}` 블록 다음, `{/* 자동 새로고침 */}` 블록 이전 columnName: string;
label: string;
**추가할 코드** (약 50줄): textColor?: string;
```tsx
{/* 그룹핑 (차트용 X축 분류) */}
{dataSource.aggregation && (
<div>
<Label className="text-xs">그룹핑 (X축)</Label>
<Popover open={groupByOpen} onOpenChange={setGroupByOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={groupByOpen}
disabled={loadingCols}
className="h-8 w-full justify-between text-xs"
>
{dataSource.aggregation.groupBy?.length
? dataSource.aggregation.groupBy.join(", ")
: "없음 (단일 값)"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
컬럼을 찾을 수 없습니다.
</CommandEmpty>
<CommandGroup>
{columns.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
const current = dataSource.aggregation?.groupBy ?? [];
const isSelected = current.includes(col.name);
const newGroupBy = isSelected
? current.filter((g) => g !== col.name)
: [...current, col.name];
onChange({
...dataSource,
aggregation: {
...dataSource.aggregation!,
groupBy: newGroupBy.length > 0 ? newGroupBy : undefined,
},
});
setGroupByOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
dataSource.aggregation?.groupBy?.includes(col.name)
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-medium">{col.name}</span>
<span className="ml-1 text-muted-foreground">({col.type})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="mt-0.5 text-[10px] text-muted-foreground">
차트에서 X축 카테고리로 사용됩니다
</p>
</div>
)}
```
**필요한 state 추가** (DataSourceEditor 내부, 기존 state 옆):
```tsx
const [groupByOpen, setGroupByOpen] = useState(false);
```
#### 변경 A-2: 차트 타입 설정에 xAxisColumn/yAxisColumn 추가 (라인 1187~1212 부근)
**추가할 위치**: `{item.subType === "chart" && (` 블록 내부, 차트 유형 Select 다음
**추가할 코드** (약 30줄):
```tsx
{/* X축 컬럼 */}
<div>
<Label className="text-xs">X축 컬럼</Label>
<Input
value={item.chartConfig?.xAxisColumn ?? ""}
onChange={(e) =>
onUpdate({
...item,
chartConfig: {
...item.chartConfig,
chartType: item.chartConfig?.chartType ?? "bar",
xAxisColumn: e.target.value || undefined,
},
})
}
placeholder="groupBy 컬럼명 (비우면 자동)"
className="h-8 text-xs"
/>
<p className="mt-0.5 text-[10px] text-muted-foreground">
그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용
</p>
</div>
```
#### 변경 A-3: 통계 카드 타입에 카테고리 설정 UI 추가 (라인 1272 부근, 게이지 설정 다음)
**추가할 위치**: `{item.subType === "gauge" && (` 블록 다음에 새 블록 추가
**추가할 코드** (약 100줄): `StatCategoryEditor` 인라인 블록
```tsx
{item.subType === "stat-card" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">카테고리 설정</Label>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={() => {
const currentCats = item.statConfig?.categories ?? [];
onUpdate({
...item,
statConfig: {
...item.statConfig,
categories: [
...currentCats,
{
label: `카테고리 ${currentCats.length + 1}`,
filter: { column: "", operator: "=", value: "" },
},
],
},
});
}}
>
<Plus className="mr-1 h-3 w-3" />
카테고리 추가
</Button>
</div>
{(item.statConfig?.categories ?? []).map((cat, catIdx) => (
<div key={catIdx} className="space-y-1 rounded border p-2">
<div className="flex items-center gap-1">
<Input
value={cat.label}
onChange={(e) => {
const newCats = [...(item.statConfig?.categories ?? [])];
newCats[catIdx] = { ...cat, label: e.target.value };
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
placeholder="라벨 (예: 수주)"
className="h-6 flex-1 text-xs"
/>
<Input
value={cat.color ?? ""}
onChange={(e) => {
const newCats = [...(item.statConfig?.categories ?? [])];
newCats[catIdx] = { ...cat, color: e.target.value || undefined };
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
placeholder="#색상코드"
className="h-6 w-20 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => {
const newCats = (item.statConfig?.categories ?? []).filter(
(_, i) => i !== catIdx
);
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 필터 조건: 컬럼 / 연산자 / 값 */}
<div className="flex items-center gap-1 text-[10px]">
<Input
value={cat.filter.column}
onChange={(e) => {
const newCats = [...(item.statConfig?.categories ?? [])];
newCats[catIdx] = {
...cat,
filter: { ...cat.filter, column: e.target.value },
};
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
placeholder="컬럼"
className="h-6 w-20 text-[10px]"
/>
<Select
value={cat.filter.operator}
onValueChange={(val) => {
const newCats = [...(item.statConfig?.categories ?? [])];
newCats[catIdx] = {
...cat,
filter: { ...cat.filter, operator: val as FilterOperator },
};
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
>
<SelectTrigger className="h-6 w-16 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=" className="text-xs">= 같음</SelectItem>
<SelectItem value="!=" className="text-xs">!= 다름</SelectItem>
<SelectItem value="like" className="text-xs">LIKE</SelectItem>
</SelectContent>
</Select>
<Input
value={String(cat.filter.value ?? "")}
onChange={(e) => {
const newCats = [...(item.statConfig?.categories ?? [])];
newCats[catIdx] = {
...cat,
filter: { ...cat.filter, value: e.target.value },
};
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
placeholder="값"
className="h-6 flex-1 text-[10px]"
/>
</div>
</div>
))}
{(item.statConfig?.categories ?? []).length === 0 && (
<p className="text-[10px] text-muted-foreground">
카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다
</p>
)}
</div>
)}
```
---
### 파일 B: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx`
**변경 유형**: 데이터 처리 로직 수정 2건
#### 변경 B-1: 차트 xAxisColumn 자동 설정 (라인 276~283 부근)
차트에 groupBy가 있지만 xAxisColumn이 설정되지 않은 경우, 첫 번째 groupBy 컬럼을 자동으로 xAxisColumn에 반영.
**현재 코드** (라인 276~283):
```tsx
case "chart":
return (
<ChartItemComponent
item={item}
rows={itemData.rows}
containerWidth={containerWidth}
/>
);
```
**변경 코드**:
```tsx
case "chart": {
// groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정
const chartItem = { ...item };
if (
item.dataSource.aggregation?.groupBy?.length &&
!item.chartConfig?.xAxisColumn
) {
chartItem.chartConfig = {
...chartItem.chartConfig,
chartType: chartItem.chartConfig?.chartType ?? "bar",
xAxisColumn: item.dataSource.aggregation.groupBy[0],
};
}
return (
<ChartItemComponent
item={chartItem}
rows={itemData.rows}
containerWidth={containerWidth}
/>
);
}
```
#### 변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297)
**현재 코드** (버그):
```tsx
case "stat-card": {
const categoryData: Record<string, number> = {};
if (item.statConfig?.categories) {
for (const cat of item.statConfig.categories) {
categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값
}
}
return (
<StatCardComponent item={item} categoryData={categoryData} />
);
} }
``` ```
**변경 코드**: **변경 코드**:
```tsx ```typescript
case "stat-card": { export interface CardFieldBinding {
const categoryData: Record<string, number> = {}; id: string;
if (item.statConfig?.categories) { label: string;
for (const cat of item.statConfig.categories) { textColor?: string;
if (cat.filter.column && cat.filter.value) { valueType: "column" | "formula"; // 값 유형: DB 컬럼 또는 계산식
// 카테고리 필터로 rows 필터링 columnName?: string; // valueType === "column"일 때 사용
const filtered = itemData.rows.filter((row) => { formula?: string; // valueType === "formula"일 때 사용 (예: "$input - received_qty")
const cellValue = String(row[cat.filter.column] ?? ""); unit?: string; // 계산식일 때 단위 표시 (예: "EA")
const filterValue = String(cat.filter.value ?? "");
switch (cat.filter.operator) {
case "=":
return cellValue === filterValue;
case "!=":
return cellValue !== filterValue;
case "like":
return cellValue.toLowerCase().includes(filterValue.toLowerCase());
default:
return cellValue === filterValue;
}
});
categoryData[cat.label] = filtered.length;
} else {
categoryData[cat.label] = itemData.rows.length;
}
}
}
return (
<StatCardComponent item={item} categoryData={categoryData} />
);
} }
``` ```
**주의**: 이 방식은 rows에 전체 데이터가 있어야 작동함. 통계 카드의 dataSource에 집계 함수를 설정하지 않고(또는 groupBy를 사용하여) 원시 rows를 가져와야 한다. **주의**: `columnName`이 required에서 optional로 변경됨. 기존 저장 데이터와의 하위 호환 필요.
#### 변경 A-2: CardInputFieldConfig 단순화
**현재 코드** (라인 443~453):
```typescript
export interface CardInputFieldConfig {
enabled: boolean;
columnName?: string;
label?: string;
unit?: string;
defaultValue?: number;
min?: number;
max?: number;
maxColumn?: string;
step?: number;
}
```
**변경 코드**:
```typescript
export interface CardInputFieldConfig {
enabled: boolean;
label?: string;
unit?: string;
limitColumn?: string; // 제한 기준 컬럼 (해당 행의 이 컬럼 값이 최대값)
saveTable?: string; // 저장 대상 테이블
saveColumn?: string; // 저장 대상 컬럼
showPackageUnit?: boolean; // 포장등록 버튼 표시 여부
}
```
**제거 항목**:
- `columnName` -> `saveTable` + `saveColumn`으로 대체 (명확한 네이밍)
- `defaultValue` -> 제거 (제한 기준 컬럼 값으로 대체)
- `min` -> 제거 (항상 0)
- `max` -> 제거 (`limitColumn`으로 대체)
- `maxColumn` -> `limitColumn`으로 이름 변경
- `step` -> 제거 (키패드 방식에서 미사용)
#### 변경 A-3: CardCalculatedFieldConfig 제거
**삭제**: `CardCalculatedFieldConfig` 인터페이스 전체 (라인 457~464)
**삭제**: `PopCardListConfig`에서 `calculatedField?: CardCalculatedFieldConfig;` 제거
---
### 파일 B: `frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx`
#### 변경 B-1: 본문 필드 편집기(FieldEditor)에 값 유형 선택 추가
**현재**: 필드 편집 시 라벨, 컬럼, 텍스트색상만 설정 가능
**변경**: 값 유형 라디오("DB 컬럼" / "계산식") 추가
- "DB 컬럼" 선택 시: 기존 컬럼 Select 표시
- "계산식" 선택 시: 수식 입력란 + 사용 가능한 컬럼/변수 칩 목록 표시
- 사용 가능한 변수: DB 컬럼명들 + `$input` (입력 필드 활성화 시)
**하위 호환**: 기존 저장 데이터에 `valueType`이 없으면 `"column"`으로 기본 처리
#### 변경 B-2: 입력 필드 설정 섹션 개편
**현재 설정 항목**: 라벨, 단위, 기본값, 최소/최대, 최대값 컬럼, 저장 컬럼
**변경 설정 항목**:
```
라벨 [입고 수량 ]
단위 [EA ]
제한 기준 컬럼 [ order_qty v ]
저장 대상 테이블 [ 선택 v ]
저장 대상 컬럼 [ 선택 v ]
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
포장등록 버튼 [on/off]
```
#### 변경 B-3: "계산 필드" 섹션 제거
**삭제**: `CalculatedFieldSettingsSection` 함수 전체
**삭제**: 카드 템플릿 탭에서 "계산 필드" CollapsibleSection 제거
#### 변경 B-4: import 정리
**삭제**: `CardCalculatedFieldConfig` import
**추가**: 없음 (기존 import 재사용)
---
### 파일 C: `frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx`
#### 변경 C-1: FieldRow에서 계산식 필드 지원
**현재**: `const value = row[field.columnName]` 로 DB 값만 표시
**변경**:
```typescript
function FieldRow({ field, row, scaled, inputValue }: {
field: CardFieldBinding;
row: RowData;
scaled: ScaledConfig;
inputValue?: number; // 입력 필드 값 (계산식에서 $input으로 참조)
}) {
const value = field.valueType === "formula" && field.formula
? evaluateFormula(field.formula, row, inputValue ?? 0)
: row[field.columnName ?? ""];
// ...
}
```
**주의**: `inputValue`를 FieldRow까지 전달해야 하므로 CardItem -> FieldRow 경로에 prop 추가 필요
#### 변경 C-2: 계산식 필드 실시간 갱신
**현재**: 별도 `calculatedValue` useMemo가 `[calculatedField, row, inputValue]`에 반응
**변경**: FieldRow가 `inputValue` prop을 받으므로, `inputValue`가 변경될 때 계산식 필드가 자동으로 리렌더링됨. 별도 useMemo 불필요.
#### 변경 C-3: 기존 calculatedField 관련 코드 제거
**삭제 대상**:
- `calculatedField` prop 전달 (CardItem)
- `calculatedValue` useMemo
- 계산 필드 렌더링 블록 (`{calculatedField?.enabled && calculatedValue !== null && (...)}`
#### 변경 C-4: 입력 필드 로직 단순화
**변경 대상**:
- `effectiveMax`: `limitColumn` 사용, 미설정 시 999999 폴백
- `defaultValue` 자동 초기화 로직 제거 (불필요)
- `NumberInputModal`에 포장등록 on/off 전달
#### 변경 C-5: NumberInputModal에 포장등록 on/off 전달
**현재**: 포장등록 버튼 항상 표시
**변경**: `showPackageUnit` prop 추가, false이면 포장등록 버튼 숨김
---
### 파일 D: `frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx`
#### 변경 D-1: showPackageUnit prop 추가
**현재 props**: open, onOpenChange, unit, initialValue, initialPackageUnit, min, maxValue, onConfirm
**추가 prop**: `showPackageUnit?: boolean` (기본값 true)
**변경**: `showPackageUnit === false`이면 포장등록 버튼 숨김
--- ---
@ -406,20 +204,21 @@ case "stat-card": {
| 순서 | 작업 | 파일 | 의존성 | 상태 | | 순서 | 작업 | 파일 | 의존성 | 상태 |
|------|------|------|--------|------| |------|------|------|--------|------|
| 1 | A-1: DataSourceEditor에 groupBy UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | | 1 | A-1: CardFieldBinding 타입 확장 | types.ts | 없음 | [ ] |
| 2 | A-2: 차트 xAxisColumn 입력 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | | 2 | A-2: CardInputFieldConfig 단순화 | types.ts | 없음 | [ ] |
| 3 | A-3: 통계 카드 카테고리 설정 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | | 3 | A-3: CardCalculatedFieldConfig 제거 | types.ts | 없음 | [ ] |
| 4 | B-1: 차트 xAxisColumn 자동 보정 로직 | PopDashboardComponent.tsx | 순서 1 | [x] | | 4 | B-1: FieldEditor에 값 유형 선택 추가 | PopCardListConfig.tsx | 순서 1 | [ ] |
| 5 | B-2: 통계 카드 카테고리별 필터 적용 | PopDashboardComponent.tsx | 순서 3 | [x] | | 5 | B-2: 입력 필드 설정 섹션 개편 | PopCardListConfig.tsx | 순서 2 | [ ] |
| 6 | 린트 검사 | 전체 | 순서 1~5 | [x] | | 6 | B-3: 계산 필드 섹션 제거 | PopCardListConfig.tsx | 순서 3 | [ ] |
| 7 | C-1: SQL 빌더 방어 로직 (빈 컬럼/테이블 차단) | dataFetcher.ts | 없음 | [x] | | 7 | B-4: import 정리 | PopCardListConfig.tsx | 순서 6 | [ ] |
| 8 | C-2: refreshInterval 최소값 강제 (5초) | PopDashboardComponent.tsx | 없음 | [x] | | 8 | D-1: NumberInputModal showPackageUnit 추가 | NumberInputModal.tsx | 없음 | [ ] |
| 9 | 브라우저 테스트 | - | 순서 1~8 | [ ] | | 9 | C-1: FieldRow 계산식 지원 | PopCardListComponent.tsx | 순서 1 | [ ] |
| 10 | C-3: calculatedField 관련 코드 제거 | PopCardListComponent.tsx | 순서 9 | [ ] |
| 11 | C-4: 입력 필드 로직 단순화 | PopCardListComponent.tsx | 순서 2, 8 | [ ] |
| 12 | 린트 검사 | 전체 | 순서 1~11 | [ ] |
순서 1, 2, 3은 서로 독립이므로 병렬 가능. 순서 1, 2, 3은 독립이므로 병렬 가능.
순서 4는 순서 1의 groupBy 값이 있어야 의미 있음. 순서 8은 독립이므로 병렬 가능.
순서 5는 순서 3의 카테고리 설정이 있어야 의미 있음.
순서 7, 8은 백엔드 부하 방지를 위한 방어 패치.
--- ---
@ -429,28 +228,21 @@ case "stat-card": {
| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 | | 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 |
|--------|------|-----------|-----------|-----------| |--------|------|-----------|-----------|-----------|
| `groupByOpen` | state (boolean) | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 | | `valueType` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 |
| `setGroupByOpen` | state setter | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 | | `formula` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 (기존 CardCalculatedFieldConfig.formula와 다른 인터페이스) |
| `chartItem` | const (DashboardItem) | PopDashboardComponent.tsx renderSingleItem 내부 | 동일 함수 내부 | 충돌 없음 | | `limitColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 |
| `saveTable` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 |
**Grep 검색 결과** (전체 pop-dashboard 폴더): | `saveColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 |
- `groupByOpen`: 0건 - 충돌 없음 | `showPackageUnit` | CardInputFieldConfig 속성 / NumberInputModal prop | types.ts, NumberInputModal.tsx | PopCardListComponent.tsx | 충돌 없음 |
- `setGroupByOpen`: 0건 - 충돌 없음
- `groupByColumns`: 0건 - 충돌 없음
- `chartItem`: 0건 - 충돌 없음
- `StatCategoryEditor`: 0건 - 충돌 없음
- `loadCategoryData`: 0건 - 충돌 없음
### 기존 타입/함수 재사용 목록 ### 기존 타입/함수 재사용 목록
| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 | | 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 |
|------------|-----------|------------------------| |------------|-----------|------------------------|
| `DataSourceConfig.aggregation.groupBy` | types.ts 라인 155 | A-1 UI에서 읽기/쓰기 | | `evaluateFormula()` | PopCardListComponent.tsx 라인 1026 | C-1: FieldRow에서 호출 (기존 함수 그대로 재사용) |
| `ChartItemConfig.xAxisColumn` | types.ts 라인 248 | A-2 UI, B-1 자동 보정 | | `CardFieldBinding` | types.ts 라인 367 | A-1에서 수정, B-1/C-1에서 사용 |
| `StatCategory` | types.ts 라인 261 | A-3 카테고리 편집 | | `CardInputFieldConfig` | types.ts 라인 443 | A-2에서 수정, B-2/C-4에서 사용 |
| `StatCardConfig.categories` | types.ts 라인 268 | A-3 UI에서 읽기/쓰기 | | `GroupedColumnSelect` | PopCardListConfig.tsx | B-1: 계산식 모드에서 컬럼 칩 표시에 재사용 가능 |
| `FilterOperator` | types.ts (import 이미 존재) | A-3 카테고리 필터 Select |
| `columns` (state) | PopDashboardConfig.tsx DataSourceEditor 내부 | A-1 groupBy 컬럼 목록 |
**사용처 있는데 정의 누락된 항목: 없음** **사용처 있는데 정의 누락된 항목: 없음**
@ -458,61 +250,81 @@ case "stat-card": {
## 5. 에러 함정 경고 ## 5. 에러 함정 경고
### 함정 1: 차트에 groupBy만 설정하고 xAxisColumn을 비우면 ### 함정 1: 기존 저장 데이터 하위 호환
ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `{status: "수주", value: 79}` 형태. 기존에 저장된 `CardFieldBinding`에는 `valueType`이 없고 `columnName`이 필수였음.
`name` 키가 없으므로 X축이 빈 채로 렌더링됨. **반드시** 런타임에서 `field.valueType || "column"` 폴백 처리해야 함.
**B-1의 자동 보정 로직이 필수**. 순서 4를 빠뜨리면 차트가 깨짐. Config UI에서도 `valueType` 미존재 시 `"column"` 기본값 적용 필요.
### 함정 2: 통계 카드에 집계 함수를 설정하면 ### 함정 2: CardInputFieldConfig 하위 호환
집계(COUNT 등)가 설정되면 rows에 `[{value: 87}]` 하나만 들어옴. 기존 `maxColumn`이 `limitColumn`으로 이름 변경됨.
카테고리별 필터링이 작동하려면 **집계 함수를 "없음"으로 두거나, groupBy를 설정**해야 개별 행이 rows에 포함됨. 기존 저장 데이터의 `maxColumn`을 `limitColumn`으로 읽어야 함.
통계 카드에서는 **집계를 사용하지 않는 것이 올바른 사용법**. 런타임: `inputField?.limitColumn || (inputField as any)?.maxColumn` 폴백 필요.
설정 가이드 문서에 이 점을 명시해야 함.
### 함정 3: PopDashboardConfig.tsx의 import 누락 ### 함정 3: evaluateFormula의 inputValue 전달
현재 `FilterOperator`는 이미 import되어 있음 (라인 54). FieldRow에 `inputValue`를 전달하려면, CardItem -> body.fields.map -> FieldRow 경로에서 `inputValue` prop을 추가해야 함.
`StatCategory`는 직접 사용하지 않고 `item.statConfig.categories` 구조로 접근하므로 import 불필요. 입력 필드가 비활성화된 경우 `inputValue`는 0으로 전달.
**새로운 import 추가 필요 없음.**
### 함정 4: 통계 카드 카테고리 필터에서 숫자 비교 ### 함정 4: calculatedField 제거 시 기존 데이터
`String(row[col])` vs `String(filter.value)` 비교이므로, 숫자 컬럼도 문자열로 비교됨. 기존 config에 `calculatedField` 데이터가 남아 있을 수 있음.
`"100" === "100"`은 정상 동작하지만, `"100.00" !== "100"`이 될 수 있음. 타입에서 제거하더라도 런타임 에러는 나지 않음 (unknown 속성은 무시됨).
현재 대부분 컬럼이 varchar이므로 문제없지만, numeric 컬럼 사용 시 주의. 다만 이전에 계산 필드로 설정한 내용은 사라짐 - 마이그레이션 없이 제거.
### 함정 5: DataSourceEditor의 columns state 타이밍 ### 함정 5: columnName optional 변경
`groupByOpen` Popover에서 `columns` 배열을 사용하는데, 테이블 선택 직후 columns가 아직 로딩 중일 수 있음. `CardFieldBinding.columnName`이 optional이 됨.
기존 코드에서 `loadingCols` 상태로 버튼을 disabled 처리하고 있으므로 문제없음. 기존에 `row[field.columnName]`으로 직접 접근하던 코드 전부 수정 필요.
`field.columnName ?? ""` 또는 valueType 분기 처리.
--- ---
## 6. 검증 방법 ## 6. 검증 방법
### 차트 (BUG-1, BUG-2) ### 시나리오 1: 기존 본문 필드 (하위 호환)
1. 아이템 추가 > "차트" 선택 1. 기존 저장된 카드리스트 열기
2. 테이블: `sales_order_mng`, 집계: COUNT, 컬럼: `id`, 그룹핑: `status` 2. 본문 필드에 기존 DB 컬럼 필드가 정상 표시되는지 확인
3. 차트 유형: 막대 차트 3. 설정 패널에서 기존 필드가 "DB 컬럼" 유형으로 표시되는지 확인
4. 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1
### 통계 카드 (BUG-3, BUG-4) ### 시나리오 2: 계산식 본문 필드 추가
1. 아이템 추가 > "통계 카드" 선택 1. 본문 필드 추가 -> 값 유형 "계산식" 선택
2. 테이블: `sales_order_mng`, **집계: 없음** (중요!) 2. 수식: `order_qty - received_qty` 입력
3. 카테고리 추가: 3. 카드에서 계산 결과가 정상 표시되는지 확인
- "수주" / status / = / 수주
- "진행중" / status / = / 진행중 ### 시나리오 3: $input 참조 계산식
- "완료" / status / = / 완료 1. 입력 필드 활성화
4. 기대 결과: 수주 79, 진행중 7, 완료 1 2. 본문 필드 추가 -> 값 유형 "계산식" -> 수식: `$input - received_qty`
3. 키패드에서 수량 입력 시 계산 결과가 실시간 갱신되는지 확인
### 시나리오 4: 제한 기준 컬럼
1. 입력 필드 -> 제한 기준 컬럼: `order_qty`
2. order_qty=1000인 카드에서 키패드 열기
3. MAX 버튼 클릭 시 1000이 입력되고, 1001 이상 입력 불가 확인
### 시나리오 5: 포장등록 on/off
1. 입력 필드 -> 포장등록 버튼: off
2. 키패드 모달에서 포장등록 버튼이 숨겨지는지 확인
--- ---
## 이전 완료 계획 (아카이브) ## 이전 완료 계획 (아카이브)
<details>
<summary>pop-dashboard 4가지 아이템 모드 완성 (완료)</summary>
- [x] groupBy UI 추가
- [x] xAxisColumn 입력 UI 추가
- [x] 통계카드 카테고리 설정 UI 추가
- [x] 차트 xAxisColumn 자동 보정 로직
- [x] 통계카드 카테고리별 필터 적용
- [x] SQL 빌더 방어 로직
- [x] refreshInterval 최소값 강제
</details>
<details> <details>
<summary>POP 뷰어 스크롤 수정 (완료)</summary> <summary>POP 뷰어 스크롤 수정 (완료)</summary>
- [x] 라인 185: overflow-hidden 제거 - [x] overflow-hidden 제거
- [x] 라인 266: overflow-auto 공통 적용 - [x] overflow-auto 공통 적용
- [x] 라인 275: 일반 모드 min-h-full 추가 - [x] 일반 모드 min-h-full 추가
- [x] 린트 검사 통과
</details> </details>
@ -521,28 +333,5 @@ ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가 - [x] 뷰어 페이지에 레지스트리 초기화 import 추가
- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체 - [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체
- [x] 린트 검사 통과
</details>
<details>
<summary>V2/V2 컴포넌트 설정 스키마 정비 (완료)</summary>
- [x] 레거시 컴포넌트 스키마 제거
- [x] V2 컴포넌트 overrides 스키마 정의 (16개)
- [x] V2 컴포넌트 overrides 스키마 정의 (9개)
- [x] componentConfig.ts 한 파일에서 통합 관리
</details>
<details>
<summary>화면 복제 기능 개선 (진행 중)</summary>
- [완료] DB 구조 개편 (menu_objid 의존성 제거)
- [완료] 복제 옵션 정리
- [완료] 화면 간 연결 복제 버그 수정
- [대기] 화면 간 연결 복제 테스트
- [대기] 제어관리 복제 테스트
- [대기] 추가 옵션 복제 테스트
</details> </details>

View File

@ -17,9 +17,9 @@
| 순서 | 작업 | 상태 | | 순서 | 작업 | 상태 |
|------|------|------| |------|------|------|
| 1 | 브라우저 확인 (라벨 정렬 동작, 반응형 자동 크기, 미리보기 반영) | [ ] 대기 | | 1 | pop-card-list 입력 필드/계산 필드 구조 개편 (PLAN.MD 참고) | [ ] 코딩 대기 |
| 2 | 게이지/통계카드 테스트 시나리오 동작 확인 | [ ] 대기 | | 2 | pop-card-list 담기 버튼 독립화 (보류) | [ ] 대기 |
| 3 | Phase 2 계획 수립 (pop-button, pop-icon) | [ ] 대기 | | 3 | pop-card-list 반응형 표시 런타임 적용 | [ ] 대기 |
--- ---

View File

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

View File

@ -17,9 +17,15 @@ router.get("/:bomId/header", bomController.getBomHeader);
router.get("/:bomId/history", bomController.getBomHistory); router.get("/:bomId/history", bomController.getBomHistory);
router.post("/:bomId/history", bomController.addBomHistory); router.post("/:bomId/history", bomController.addBomHistory);
// 엑셀 업로드/다운로드
router.post("/excel-upload", bomController.createBomFromExcel);
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
// 버전 // 버전
router.get("/:bomId/versions", bomController.getBomVersions); router.get("/:bomId/versions", bomController.getBomVersions);
router.post("/:bomId/versions", bomController.createBomVersion); router.post("/:bomId/versions", bomController.createBomVersion);
router.post("/:bomId/initialize-version", bomController.initializeBomVersion);
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion); router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion); router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion); router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);

View File

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

View File

@ -210,19 +210,62 @@ export class DynamicFormService {
} }
} }
/**
* VIEW인 (base) ,
*/
async resolveBaseTable(tableName: string): Promise<string> {
try {
const result = await query<{ table_type: string }>(
`SELECT table_type FROM information_schema.tables
WHERE table_name = $1 AND table_schema = 'public'`,
[tableName]
);
if (result.length === 0 || result[0].table_type !== 'VIEW') {
return tableName;
}
// VIEW의 FROM 절에서 첫 번째 테이블을 추출
const viewDef = await query<{ view_definition: string }>(
`SELECT view_definition FROM information_schema.views
WHERE table_name = $1 AND table_schema = 'public'`,
[tableName]
);
if (viewDef.length > 0) {
const definition = viewDef[0].view_definition;
// PostgreSQL은 뷰 정의를 "FROM (테이블명 별칭 LEFT JOIN ...)" 형태로 저장
const fromMatch = definition.match(/FROM\s+\(?(?:public\.)?(\w+)\s/i);
if (fromMatch) {
const baseTable = fromMatch[1];
console.log(`🔄 VIEW ${tableName} → 원본 테이블 ${baseTable} 으로 전환`);
return baseTable;
}
}
return tableName;
} catch (error) {
console.error(`❌ VIEW 원본 테이블 조회 실패:`, error);
return tableName;
}
}
/** /**
* ( ) * ( )
*/ */
async saveFormData( async saveFormData(
screenId: number, screenId: number,
tableName: string, tableNameInput: string,
data: Record<string, any>, data: Record<string, any>,
ipAddress?: string ipAddress?: string
): Promise<FormDataResult> { ): Promise<FormDataResult> {
// VIEW인 경우 원본 테이블로 전환
const tableName = await this.resolveBaseTable(tableNameInput);
try { try {
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", { console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
screenId, screenId,
tableName, tableName,
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
data, data,
}); });
@ -813,14 +856,17 @@ export class DynamicFormService {
*/ */
async updateFormDataPartial( async updateFormDataPartial(
id: string | number, // 🔧 UUID 문자열도 지원 id: string | number, // 🔧 UUID 문자열도 지원
tableName: string, tableNameInput: string,
originalData: Record<string, any>, originalData: Record<string, any>,
newData: Record<string, any> newData: Record<string, any>
): Promise<PartialUpdateResult> { ): Promise<PartialUpdateResult> {
// VIEW인 경우 원본 테이블로 전환
const tableName = await this.resolveBaseTable(tableNameInput);
try { try {
console.log("🔄 서비스: 부분 업데이트 시작:", { console.log("🔄 서비스: 부분 업데이트 시작:", {
id, id,
tableName, tableName,
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
originalData, originalData,
newData, newData,
}); });
@ -1008,13 +1054,16 @@ export class DynamicFormService {
*/ */
async updateFormData( async updateFormData(
id: string | number, id: string | number,
tableName: string, tableNameInput: string,
data: Record<string, any> data: Record<string, any>
): Promise<FormDataResult> { ): Promise<FormDataResult> {
// VIEW인 경우 원본 테이블로 전환
const tableName = await this.resolveBaseTable(tableNameInput);
try { try {
console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", { console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", {
id, id,
tableName, tableName,
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
data, data,
}); });
@ -1033,6 +1082,9 @@ export class DynamicFormService {
if (tableColumns.includes("updated_at")) { if (tableColumns.includes("updated_at")) {
dataToUpdate.updated_at = new Date(); dataToUpdate.updated_at = new Date();
} }
if (tableColumns.includes("updated_date")) {
dataToUpdate.updated_date = new Date();
}
if (tableColumns.includes("regdate") && !dataToUpdate.regdate) { if (tableColumns.includes("regdate") && !dataToUpdate.regdate) {
dataToUpdate.regdate = new Date(); dataToUpdate.regdate = new Date();
} }
@ -1212,9 +1264,13 @@ export class DynamicFormService {
screenId?: number screenId?: number
): Promise<void> { ): Promise<void> {
try { try {
// VIEW인 경우 원본 테이블로 전환 (VIEW에는 기본키가 없으므로)
const actualTable = await this.resolveBaseTable(tableName);
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
id, id,
tableName, tableName: actualTable,
originalTable: tableName !== actualTable ? tableName : undefined,
}); });
// 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회 // 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회
@ -1232,15 +1288,15 @@ export class DynamicFormService {
`; `;
console.log("🔍 기본키 조회 SQL:", primaryKeyQuery); console.log("🔍 기본키 조회 SQL:", primaryKeyQuery);
console.log("🔍 테이블명:", tableName); console.log("🔍 테이블명:", actualTable);
const primaryKeyResult = await query<{ const primaryKeyResult = await query<{
column_name: string; column_name: string;
data_type: string; data_type: string;
}>(primaryKeyQuery, [tableName]); }>(primaryKeyQuery, [actualTable]);
if (!primaryKeyResult || primaryKeyResult.length === 0) { if (!primaryKeyResult || primaryKeyResult.length === 0) {
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); throw new Error(`테이블 ${actualTable}의 기본키를 찾을 수 없습니다.`);
} }
const primaryKeyInfo = primaryKeyResult[0]; const primaryKeyInfo = primaryKeyResult[0];
@ -1272,7 +1328,7 @@ export class DynamicFormService {
// 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성 // 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성
const deleteQuery = ` const deleteQuery = `
DELETE FROM ${tableName} DELETE FROM ${actualTable}
WHERE ${primaryKeyColumn} = $1${typeCastSuffix} WHERE ${primaryKeyColumn} = $1${typeCastSuffix}
RETURNING * RETURNING *
`; `;
@ -1292,7 +1348,7 @@ export class DynamicFormService {
// 삭제된 행이 없으면 레코드를 찾을 수 없는 것 // 삭제된 행이 없으면 레코드를 찾을 수 없는 것
if (!result || !Array.isArray(result) || result.length === 0) { if (!result || !Array.isArray(result) || result.length === 0) {
throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`); throw new Error(`테이블 ${actualTable}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
} }
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);

85
bom-restore-verify.mjs Normal file
View File

@ -0,0 +1,85 @@
/**
* BOM Screen - Restoration Verification
* Screen 4168 - verify split panel, BOM list, and tree with child items
*/
import { chromium } from 'playwright';
import { mkdirSync, existsSync } from 'fs';
import { join } from 'path';
const SCREENSHOT_DIR = join(process.cwd(), 'bom-detail-test-screenshots');
async function ensureDir(dir) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
async function screenshot(page, name) {
ensureDir(SCREENSHOT_DIR);
await page.screenshot({ path: join(SCREENSHOT_DIR, `${name}.png`), fullPage: true });
console.log(` [Screenshot] ${name}.png`);
}
async function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function main() {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
try {
console.log('\n--- Step 1-2: Login ---');
await page.goto('http://localhost:9771/login', { waitUntil: 'load', timeout: 45000 });
await page.locator('input[type="text"], input[placeholder*="ID"]').first().fill('topseal_admin');
await page.locator('input[type="password"]').first().fill('qlalfqjsgh11');
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {}),
page.locator('button:has-text("로그인")').first().click(),
]);
await sleep(3000);
console.log('\n--- Step 4-5: Navigate to screen 4168 ---');
await page.goto('http://localhost:9771/screens/4168', { waitUntil: 'load', timeout: 45000 });
await sleep(5000);
console.log('\n--- Step 6: Screenshot after load ---');
await screenshot(page, '10-bom-4168-initial');
const hasBomList = (await page.locator('text="BOM 목록"').count()) > 0;
const hasSplitPanel = (await page.locator('text="BOM 상세정보"').count()) > 0 || hasBomList;
const rowCount = await page.locator('table tbody tr').count();
const hasBomRows = rowCount > 0;
console.log('\n========== INITIAL STATE (Step 7) ==========');
console.log('BOM management screen loaded:', hasBomList || hasSplitPanel ? 'YES' : 'CHECK');
console.log('Split panel (BOM list left):', hasSplitPanel ? 'YES' : 'NO');
console.log('BOM data rows visible:', hasBomRows ? `YES (${rowCount} rows)` : 'NO');
if (hasBomRows) {
console.log('\n--- Step 8-9: Click first row ---');
await page.locator('table tbody tr').first().click();
await sleep(5000);
console.log('\n--- Step 10: Screenshot after row click ---');
await screenshot(page, '11-bom-4168-after-click');
const noDataMsg = (await page.locator('text="등록된 하위 품목이 없습니다"').count()) > 0;
const treeArea = page.locator('div:has-text("BOM 구성"), div:has-text("BOM 상세정보")').first();
const treeText = (await treeArea.textContent().catch(() => '') || '').substring(0, 600);
const hasChildItems = !noDataMsg && (treeText.includes('품번') || treeText.includes('레벨') || treeText.length > 150);
console.log('\n========== AFTER ROW CLICK (Step 11) ==========');
console.log('BOM tree shows child items:', hasChildItems ? 'YES' : noDataMsg ? 'NO (empty message)' : 'CHECK');
console.log('Tree preview:', treeText.substring(0, 300) + (treeText.length > 300 ? '...' : ''));
} else {
console.log('\n--- No BOM rows to click ---');
}
} catch (err) {
console.error('Error:', err.message);
try { await page.screenshot({ path: join(SCREENSHOT_DIR, '99-error.png'), fullPage: true }); } catch (e) {}
} finally {
await browser.close();
}
}
main();

271
bom-save-console-logs.txt Normal file
View File

@ -0,0 +1,271 @@
[info] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[warning] Image with src "/images/vexplor.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio.
[log] 첫 번째 접근 가능한 메뉴로 이동: /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[log] 📦 메인 테이블 데이터 자동 로드: company_mng {company_code: COMPANY_7, company_name: 탑씰 테스트, writer: wace, regdate: 2026-02-27T09:28:35.342Z, status: active}
[log] 📦 메인 테이블 데이터 자동 로드: company_mng {company_code: COMPANY_7, company_name: 탑씰 테스트, writer: wace, regdate: 2026-02-27T09:28:35.342Z, status: active}
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 1030135068124796000, error: 404}
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 666667496384701400, error: 404}
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 88591267128165600, error: 404}
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 1030135068124796000, error: 404}
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 666667496384701400, error: 404}
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 88591267128165600, error: 404}
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[info] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 0 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 0 leftGroupSumConfig: null
[log] 📦 [SplitPanelLayout] Context에 분할 패널 등록: {splitPanelId: split-panel-comp_split_panel, panelInfo: Object}
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
[log] 📦 [SplitPanelLayout] Context에서 분할 패널 해제: split-panel-comp_split_panel
[log] 📦 [SplitPanelLayout] Context에 분할 패널 등록: {splitPanelId: split-panel-comp_split_panel, panelInfo: Object}
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] ✅ 좌측 컬럼 라벨 로드: {id: ID, created_date: 생성일시, updated_date: 수정일시, writer: 작성자, company_code: 회사코드}
[log] ✅ 좌측 컬럼 라벨 로드: {id: ID, created_date: 생성일시, updated_date: 수정일시, writer: 작성자, company_code: 회사코드}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] ✅ 좌측 카테고리 매핑 로드 [status]: {CAT_MM3XFDT6_YULY: Object, CAT_MM3XFA7B_ZFD6: Object}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] ✅ 좌측 카테고리 매핑 로드 [status]: {CAT_MM3XFDT6_YULY: Object, CAT_MM3XFA7B_ZFD6: Object}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] ✅ 분할 패널 좌측 선택: bom {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
[log] 🔴 [ButtonPrimary] 저장 시 formData 디버그: {propsFormDataKeys: Array(70), screenContextFormDataKeys: Array(0), effectiveFormDataKeys: Array(70), process_code: undefined, equipment_code: undefined}
[log] [BomTree] openEditModal 가로채기 - editData 보정 {oldVersion: 1.0, newVersion: 1.0, oldCurrentVersionId: de575ae5-266c-42f0-be49-bcc65de89ebd, newCurrentVersionId: de575ae5-266c-42f0-be49-bcc65de89ebd}
[log] 🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] [EditModal] 모달 열림: {mode: UPDATE (수정), hasEditData: true, editDataId: 64617576-fec9-4caa-8e72-653f9e83ba45, isCreateMode: false}
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] [EditModal] 화면 데이터 로드 완료, 조건부 레이어 로드 시작: 4154
[log] [EditModal] loadConditionalLayersAndZones 호출됨: 4154
[log] [EditModal] API 호출 시작: getScreenLayers, getScreenZones
[log] [EditModal] API 응답: {layers: 1, zones: 0}
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import { Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
/** /**
* /screen/COMPANY_7_167 /screens/4153 * /screen/{screenCode} /screens/{screenId}
* URL이 screenCode , screenId로 * URL이 screenCode , screenId로
*/ */
export default function ScreenCodeRedirectPage() { export default function ScreenCodeRedirectPage() {
@ -26,12 +26,14 @@ export default function ScreenCodeRedirectPage() {
const resolve = async () => { const resolve = async () => {
try { try {
const res = await apiClient.get("/screen-management/screens", { const res = await apiClient.get("/screen-management/screens", {
params: { screenCode }, params: { searchTerm: screenCode, size: 50 },
}); });
const screens = res.data?.data || []; const items = res.data?.data?.data || res.data?.data || [];
if (screens.length > 0) { const arr = Array.isArray(items) ? items : [];
const id = screens[0].screenId || screens[0].screen_id; const exact = arr.find((s: any) => s.screenCode === screenCode);
router.replace(`/screens/${id}`); const target = exact || arr[0];
if (target) {
router.replace(`/screens/${target.screenId || target.screen_id}`);
} else { } else {
router.replace("/"); router.replace("/");
} }

View File

@ -903,7 +903,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
} }
for (const row of filteredData) { for (let rowIdx = 0; rowIdx < filteredData.length; rowIdx++) {
const row = filteredData[rowIdx];
try { try {
let dataToSave = { ...row }; let dataToSave = { ...row };
let shouldSkip = false; let shouldSkip = false;
@ -925,15 +926,16 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
if (existingDataMap.has(key)) { if (existingDataMap.has(key)) {
existingRow = existingDataMap.get(key); existingRow = existingDataMap.get(key);
// 중복 발견 - 전역 설정에 따라 처리
if (duplicateAction === "skip") { if (duplicateAction === "skip") {
shouldSkip = true; shouldSkip = true;
skipCount++; skipCount++;
console.log(`⏭️ 중복으로 건너뛰기: ${key}`); console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
} else { } else {
shouldUpdate = true; shouldUpdate = true;
console.log(`🔄 중복으로 덮어쓰기: ${key}`); console.log(`🔄 [행 ${rowIdx + 1}] 중복으로 덮어쓰기: ${key}`);
} }
} else {
console.log(`✅ [행 ${rowIdx + 1}] 중복 아님 (신규 데이터): ${key}`);
} }
} }
@ -943,7 +945,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
// 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용 // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { if (hasNumbering && numberingInfo && (uploadMode === "insert" || uploadMode === "upsert") && !shouldUpdate) {
const existingValue = dataToSave[numberingInfo.columnName]; const existingValue = dataToSave[numberingInfo.columnName];
const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== ""; const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
@ -968,24 +970,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
tableName, tableName,
data: dataToSave, data: dataToSave,
}; };
console.log(`📝 [행 ${rowIdx + 1}] 덮어쓰기 시도: id=${existingRow.id}`, dataToSave);
const result = await DynamicFormApi.updateFormData(existingRow.id, formData); const result = await DynamicFormApi.updateFormData(existingRow.id, formData);
if (result.success) { if (result.success) {
overwriteCount++; overwriteCount++;
successCount++; successCount++;
} else { } else {
console.error(`❌ [행 ${rowIdx + 1}] 덮어쓰기 실패:`, result.message);
failCount++; failCount++;
} }
} else if (uploadMode === "insert") { } else if (uploadMode === "insert" || uploadMode === "upsert") {
// 신규 등록 // 신규 등록 (insert, upsert 모드)
const formData = { screenId: 0, tableName, data: dataToSave }; const formData = { screenId: 0, tableName, data: dataToSave };
console.log(`📝 [행 ${rowIdx + 1}] 신규 등록 시도 (mode: ${uploadMode}):`, dataToSave);
const result = await DynamicFormApi.saveFormData(formData); const result = await DynamicFormApi.saveFormData(formData);
if (result.success) { if (result.success) {
successCount++; successCount++;
console.log(`✅ [행 ${rowIdx + 1}] 신규 등록 성공`);
} else { } else {
console.error(`❌ [행 ${rowIdx + 1}] 신규 등록 실패:`, result.message);
failCount++; failCount++;
} }
} else if (uploadMode === "update") {
// update 모드에서 기존 데이터가 없는 행은 건너뛰기
console.log(`⏭️ [행 ${rowIdx + 1}] update 모드: 기존 데이터 없음, 건너뛰기`);
skipCount++;
} }
} catch (error) { } catch (error: any) {
console.error(`❌ [행 ${rowIdx + 1}] 업로드 처리 오류:`, error?.response?.data || error?.message || error);
failCount++; failCount++;
} }
} }
@ -1008,8 +1020,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
} }
console.log(`📊 엑셀 업로드 결과 요약: 성공=${successCount}, 건너뛰기=${skipCount}, 덮어쓰기=${overwriteCount}, 실패=${failCount}`);
if (successCount > 0 || skipCount > 0) { if (successCount > 0 || skipCount > 0) {
// 상세 결과 메시지 생성
let message = ""; let message = "";
if (successCount > 0) { if (successCount > 0) {
message += `${successCount}개 행 업로드`; message += `${successCount}개 행 업로드`;
@ -1022,15 +1035,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
message += `중복 건너뛰기 ${skipCount}`; message += `중복 건너뛰기 ${skipCount}`;
} }
if (failCount > 0) { if (failCount > 0) {
message += ` (실패: ${failCount})`; message += `, 실패 ${failCount}`;
} }
if (failCount > 0 && successCount === 0) {
toast.warning(message);
} else {
toast.success(message); toast.success(message);
}
// 매핑 템플릿 저장 // 매핑 템플릿 저장
await saveMappingTemplateInternal(); await saveMappingTemplateInternal();
if (successCount > 0 || overwriteCount > 0) {
onSuccess?.(); onSuccess?.();
}
} else if (failCount > 0) {
toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`);
} else { } else {
toast.error("업로드에 실패했습니다."); toast.error("업로드에 실패했습니다.");
} }

View File

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

View File

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

View File

@ -284,7 +284,26 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}); });
// 편집 데이터로 폼 데이터 초기화 // 편집 데이터로 폼 데이터 초기화
setFormData(editData || {}); // entity join 필드(xxx_yyy)를 dot notation(table.column)으로도 매핑
const enriched = { ...(editData || {}) };
if (editData) {
Object.keys(editData).forEach((key) => {
// item_id_item_name → item_info.item_name 패턴 변환
const match = key.match(/^(.+?)_([a-z_]+)$/);
if (match && editData[key] != null) {
const [, fkCol, fieldName] = match;
// FK가 _id로 끝나면 참조 테이블명 추론 (item_id → item_info)
if (fkCol.endsWith("_id")) {
const refTable = fkCol.replace(/_id$/, "_info");
const dotKey = `${refTable}.${fieldName}`;
if (!(dotKey in enriched)) {
enriched[dotKey] = editData[key];
}
}
}
});
}
setFormData(enriched);
// originalData: changedData 계산(PATCH)에만 사용 // originalData: changedData 계산(PATCH)에만 사용
// INSERT/UPDATE 판단에는 사용하지 않음 // INSERT/UPDATE 판단에는 사용하지 않음
setOriginalData(isCreateMode ? {} : editData || {}); setOriginalData(isCreateMode ? {} : editData || {});
@ -1211,7 +1230,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
} }
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) // V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
const hasRepeaterForInsert = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterForInsert) {
try { try {
const repeaterSavePromise = new Promise<void>((resolve) => { const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000); const fallbackTimeout = setTimeout(resolve, 5000);
@ -1223,11 +1244,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
window.addEventListener("repeaterSaveComplete", handler); window.addEventListener("repeaterSaveComplete", handler);
}); });
console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
});
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("repeaterSave", { new CustomEvent("repeaterSave", {
detail: { detail: {
@ -1240,10 +1256,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
); );
await repeaterSavePromise; await repeaterSavePromise;
console.log("✅ [EditModal] INSERT 후 repeaterSave 완료");
} catch (repeaterError) { } catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
} }
}
handleClose(); handleClose();
} else { } else {
@ -1251,8 +1267,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
} }
} else { } else {
// UPDATE 모드 - PUT (전체 업데이트) // UPDATE 모드 - PUT (전체 업데이트)
// originalData 비교 없이 formData 전체를 보냄 // VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조)
const recordId = formData.id; const recordId = formData.master_id || formData.id;
if (!recordId) { if (!recordId) {
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", { console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
@ -1305,15 +1321,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (response.success) { if (response.success) {
toast.success("데이터가 수정되었습니다."); toast.success("데이터가 수정되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try { try {
@ -1350,7 +1357,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
} }
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) // V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
const hasRepeaterForUpdate = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterForUpdate) {
try { try {
const repeaterSavePromise = new Promise<void>((resolve) => { const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000); const fallbackTimeout = setTimeout(resolve, 5000);
@ -1362,11 +1371,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
window.addEventListener("repeaterSaveComplete", handler); window.addEventListener("repeaterSaveComplete", handler);
}); });
console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
});
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("repeaterSave", { new CustomEvent("repeaterSave", {
detail: { detail: {
@ -1379,11 +1383,15 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
); );
await repeaterSavePromise; await repeaterSavePromise;
console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료");
} catch (repeaterError) { } catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
} }
}
// 리피터 저장 완료 후 메인 테이블 새로고침
if (modalState.onSave) {
try { modalState.onSave(); } catch {}
}
handleClose(); handleClose();
} else { } else {
throw new Error(response.message || "수정에 실패했습니다."); throw new Error(response.message || "수정에 실패했습니다.");

View File

@ -245,23 +245,29 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
}; };
// 라벨 렌더링 // 라벨 렌더링
const labelPos = widget.style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const renderLabel = () => { const renderLabel = () => {
if (hideLabel) return null; if (hideLabel) return null;
const labelStyle = widget.style || {}; const ls = widget.style || {};
const labelElement = ( const labelElement = (
<label <label
className={`mb-2 block text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`} className={`text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
style={{ style={{
fontSize: labelStyle.labelFontSize || "14px", fontSize: ls.labelFontSize || "14px",
color: hasError ? "hsl(var(--destructive))" : labelStyle.labelColor || undefined, color: hasError ? "hsl(var(--destructive))" : ls.labelColor || undefined,
fontWeight: labelStyle.labelFontWeight || "500", fontWeight: ls.labelFontWeight || "500",
fontFamily: labelStyle.labelFontFamily, fontFamily: ls.labelFontFamily,
textAlign: labelStyle.labelTextAlign || "left", textAlign: ls.labelTextAlign || "left",
backgroundColor: labelStyle.labelBackgroundColor, backgroundColor: ls.labelBackgroundColor,
padding: labelStyle.labelPadding, padding: ls.labelPadding,
borderRadius: labelStyle.labelBorderRadius, borderRadius: ls.labelBorderRadius,
marginBottom: labelStyle.labelMarginBottom || "8px", ...(isHorizLabel
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
: { marginBottom: labelPos === "top" ? (ls.labelMarginBottom || "8px") : undefined,
marginTop: labelPos === "bottom" ? (ls.labelMarginBottom || "8px") : undefined }),
}} }}
> >
{widget.label} {widget.label}
@ -332,11 +338,28 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
} }
}; };
const labelElement = renderLabel();
const widgetElement = renderByWebType();
const validationElement = renderFieldValidation();
if (isHorizLabel && labelElement) {
return ( return (
<div key={comp.id} className="space-y-2"> <div key={comp.id}>
{renderLabel()} <div style={{ display: "flex", flexDirection: labelPos === "left" ? "row" : "row-reverse", alignItems: "center", gap: widget.style?.labelGap || "8px" }}>
{renderByWebType()} {labelElement}
{renderFieldValidation()} <div style={{ flex: 1, minWidth: 0 }}>{widgetElement}</div>
</div>
{validationElement}
</div>
);
}
return (
<div key={comp.id}>
{labelPos === "top" && labelElement}
{widgetElement}
{labelPos === "bottom" && labelElement}
{validationElement}
</div> </div>
); );
}; };

View File

@ -2208,15 +2208,21 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}); });
} }
// 라벨 스타일 적용 // 라벨 위치 및 스타일
const labelStyle = { const labelPosition = component.style?.labelPosition || "top";
const isHorizontalLabel = labelPosition === "left" || labelPosition === "right";
const labelGap = component.style?.labelGap || "8px";
const labelStyle: React.CSSProperties = {
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#212121", color: component.style?.labelColor || "#212121",
fontWeight: component.style?.labelFontWeight || "500", fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent", backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0", padding: component.style?.labelPadding || "0",
borderRadius: component.style?.labelBorderRadius || "0", borderRadius: component.style?.labelBorderRadius || "0",
marginBottom: component.style?.labelMarginBottom || "4px", ...(isHorizontalLabel
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
: { marginBottom: component.style?.labelMarginBottom || "4px" }),
}; };
@ -2452,18 +2458,45 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
{/* 테이블 옵션 툴바 */} {/* 테이블 옵션 툴바 */}
<TableOptionsToolbar /> <TableOptionsToolbar />
{/* 메인 컨텐츠 */} {/* 메인 컨텐츠 - 라벨 위치에 따라 flex 방향 변경 */}
<div className="h-full flex-1" style={{ width: '100%' }}> <div
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */} className="h-full flex-1"
{shouldShowLabel && ( style={{
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> width: '100%',
...(shouldShowLabel && isHorizontalLabel
? { display: 'flex', flexDirection: labelPosition === 'left' ? 'row' : 'row-reverse', alignItems: 'center', gap: labelGap }
: {}),
}}
>
{/* 라벨: top 또는 left일 때 위젯보다 먼저 렌더링 */}
{shouldShowLabel && (labelPosition === "top" || labelPosition === "left") && (
<label
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
style={labelStyle}
>
{labelText} {labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>} {(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label> </label>
)} )}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */} {/* 실제 위젯 */}
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div> <div className="h-full" style={{ width: '100%', height: '100%', ...(isHorizontalLabel ? { flex: 1, minWidth: 0 } : {}) }}>
{renderInteractiveWidget(componentForRendering)}
</div>
{/* 라벨: bottom 또는 right일 때 위젯 뒤에 렌더링 */}
{shouldShowLabel && (labelPosition === "bottom" || labelPosition === "right") && (
<label
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
style={{
...labelStyle,
...(labelPosition === "bottom" ? { marginBottom: 0, marginTop: component.style?.labelMarginBottom || "4px" } : {}),
}}
>
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
</div> </div>
</div> </div>

View File

@ -1103,17 +1103,21 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// TableSearchWidget의 경우 높이를 자동으로 설정 // TableSearchWidget의 경우 높이를 자동으로 설정
const isTableSearchWidget = (component as any).componentId === "table-search-widget"; const isTableSearchWidget = (component as any).componentId === "table-search-widget";
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트) // 라벨 표시 여부 확인 (V2 입력 컴포넌트)
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시 const compType = (component as any).componentType || "";
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date"; const isV2InputComponent =
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
const hasVisibleLabel = isV2InputComponent && const hasVisibleLabel = isV2InputComponent &&
style?.labelDisplay !== false && style?.labelDisplay !== false &&
(style?.labelText || (component as any).label); (style?.labelText || (component as any).label);
// 라벨이 있는 경우 상단 여백 계산 (라벨 폰트크기 + 여백) // 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
const labelPos = style?.labelPosition || "top";
const isVerticalLabel = labelPos === "top" || labelPos === "bottom";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0; const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
const calculateCanvasSplitX = (): { x: number; w: number } => { const calculateCanvasSplitX = (): { x: number; w: number } => {
const compType = (component as any).componentType || ""; const compType = (component as any).componentType || "";
@ -1263,10 +1267,56 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return unsubscribe; return unsubscribe;
}, [component.id, position?.x, size?.width, type]); }, [component.id, position?.x, size?.width, type]);
// 라벨 위치가 top이 아닌 경우: 외부에서 라벨을 렌더링하고 내부 라벨은 숨김
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelText = style?.labelText || (component as any).label || "";
const labelGapValue = style?.labelGap || "8px";
const externalLabelComponent = needsExternalLabel ? (
<label
className="text-sm font-medium leading-none"
style={{
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#212121",
fontWeight: style?.labelFontWeight || "500",
...(isHorizLabel ? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" } : {}),
...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}),
}}
>
{labelText}
{((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
)}
</label>
) : null;
const componentToRender = needsExternalLabel
? { ...splitAdjustedComponent, style: { ...splitAdjustedComponent.style, labelDisplay: false } }
: splitAdjustedComponent;
return ( return (
<> <>
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}> <div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{renderInteractiveWidget(splitAdjustedComponent)} {needsExternalLabel ? (
<div
style={{
display: "flex",
flexDirection: isHorizLabel ? (labelPos === "left" ? "row" : "row-reverse") : "column-reverse",
alignItems: isHorizLabel ? "center" : undefined,
gap: isHorizLabel ? labelGapValue : undefined,
width: "100%",
height: "100%",
}}
>
{externalLabelComponent}
<div style={{ flex: 1, minWidth: 0, height: isHorizLabel ? "100%" : undefined }}>
{renderInteractiveWidget(componentToRender)}
</div>
</div>
) : (
renderInteractiveWidget(componentToRender)
)}
</div> </div>
{/* 팝업 화면 렌더링 */} {/* 팝업 화면 렌더링 */}

View File

@ -841,6 +841,44 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
className="h-6 w-full px-2 py-0 text-xs" className="h-6 w-full px-2 py-0 text-xs"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select
value={selectedComponent.style?.labelPosition || "top"}
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={
(selectedComponent.style?.labelPosition === "left" || selectedComponent.style?.labelPosition === "right")
? (selectedComponent.style?.labelGap || "8px")
: (selectedComponent.style?.labelMarginBottom || "4px")
}
onChange={(e) => {
const pos = selectedComponent.style?.labelPosition;
if (pos === "left" || pos === "right") {
handleUpdate("style.labelGap", e.target.value);
} else {
handleUpdate("style.labelMarginBottom", e.target.value);
}
}}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
@ -862,12 +900,21 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
<Input <Select
value={selectedComponent.style?.labelMarginBottom || "4px"} value={selectedComponent.style?.labelFontWeight || "500"}
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)} onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
className="h-6 w-full px-2 py-0 text-xs" >
/> <SelectTrigger className="h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="400"></SelectItem>
<SelectItem value="500"></SelectItem>
<SelectItem value="600"></SelectItem>
<SelectItem value="700"> </SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="flex items-center space-x-2 pt-5"> <div className="flex items-center space-x-2 pt-5">
<Checkbox <Checkbox

View File

@ -704,10 +704,56 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정) // 라벨 위치 및 높이 계산
const labelPos = style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const labelGapValue = style?.labelGap || "8px";
const labelElement = showLabel ? (
<Label
htmlFor={id}
style={{
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
) : null;
const dateContent = (
<div className={isHorizLabel ? "min-w-0 flex-1" : "h-full w-full"} style={isHorizLabel ? { height: "100%" } : undefined}>
{renderDatePicker()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
display: "flex",
flexDirection: labelPos === "left" ? "row" : "row-reverse",
alignItems: "center",
gap: labelGapValue,
}}
>
{labelElement}
{dateContent}
</div>
);
}
return ( return (
<div <div
@ -719,27 +765,8 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} {labelElement}
{showLabel && ( {dateContent}
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div className="h-full w-full">
{renderDatePicker()}
</div>
</div> </div>
); );
}); });

View File

@ -961,36 +961,83 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
} }
}; };
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
// 🔧 label prop이 없어도 style.labelText에서 가져올 수 있도록 수정
const actualLabel = label || style?.labelText; const actualLabel = label || style?.labelText;
const showLabel = actualLabel && style?.labelDisplay === true; const showLabel = actualLabel && style?.labelDisplay === true;
// size에서 우선 가져오고, 없으면 style에서 가져옴
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정) // 라벨 위치 및 높이 계산
const labelPos = style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백 const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const labelGapValue = style?.labelGap || "8px";
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일) // 커스텀 스타일 감지
// RealtimePreview 래퍼가 외부 div에 스타일을 적용하지만,
// 내부 input/textarea가 자체 Tailwind 테두리를 가지므로 이를 제거하여 외부 스타일이 보이도록 함
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border); const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor; const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius; const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (내부 input/textarea에 직접 전달)
const customTextStyle: React.CSSProperties = {}; const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color; if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize; if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight; if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0; const hasCustomText = Object.keys(customTextStyle).length > 0;
// 내부 input에 직접 적용할 텍스트 스타일 (fontSize, color, fontWeight, textAlign)
const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined; const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined;
const labelElement = showLabel ? (
<Label
htmlFor={id}
style={{
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{actualLabel}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
) : null;
const inputContent = (
<div
className={cn(
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
)}
style={{ ...(hasCustomText ? customTextStyle : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
>
{renderInput()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
display: "flex",
flexDirection: labelPos === "left" ? "row" : "row-reverse",
alignItems: "center",
gap: labelGapValue,
}}
>
{labelElement}
{inputContent}
</div>
);
}
return ( return (
<div <div
ref={ref} ref={ref}
@ -1001,38 +1048,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */} {labelElement}
{showLabel && ( {inputContent}
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{actualLabel}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div
className={cn(
"h-full w-full",
// 커스텀 테두리 설정 시, 내부 input/textarea의 기본 테두리 제거 (외부 래퍼 스타일이 보이도록)
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거 (외부 래퍼가 처리)
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
// 커스텀 배경 설정 시, 내부 input을 투명하게 (외부 배경이 보이도록)
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
)}
style={hasCustomText ? customTextStyle : undefined}
>
{renderInput()}
</div>
</div> </div>
); );
}); });

View File

@ -50,9 +50,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
formData: parentFormData, formData: parentFormData,
...restProps ...restProps
}) => { }) => {
// ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용)
const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData;
// componentId 결정: 직접 전달 또는 component 객체에서 추출 // componentId 결정: 직접 전달 또는 component 객체에서 추출
const effectiveComponentId = componentId || (restProps as any).component?.id; const effectiveComponentId = componentId || (restProps as any).component?.id;
@ -214,21 +211,20 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const isModalMode = config.renderMode === "modal"; const isModalMode = config.renderMode === "modal";
// 전역 리피터 등록 // 전역 리피터 등록
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블) // tableName이 비어있어도 반드시 등록 (repeaterSave 이벤트 발행 가드에 필요)
useEffect(() => { useEffect(() => {
const targetTableName = const targetTableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
const registrationKey = targetTableName || "__v2_repeater_same_table__";
if (targetTableName) {
if (!window.__v2RepeaterInstances) { if (!window.__v2RepeaterInstances) {
window.__v2RepeaterInstances = new Set(); window.__v2RepeaterInstances = new Set();
} }
window.__v2RepeaterInstances.add(targetTableName); window.__v2RepeaterInstances.add(registrationKey);
}
return () => { return () => {
if (targetTableName && window.__v2RepeaterInstances) { if (window.__v2RepeaterInstances) {
window.__v2RepeaterInstances.delete(targetTableName); window.__v2RepeaterInstances.delete(registrationKey);
} }
}; };
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]); }, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
@ -428,7 +424,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
{ {
page: 1, page: 1,
size: 1000, size: 1000,
search: { [config.foreignKeyColumn]: fkValue }, dataFilter: {
enabled: true,
filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }],
},
autoFilter: true, autoFilter: true,
} }
); );
@ -965,90 +964,8 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
[], [],
); );
// 모달에서 전달된 groupedData를 초기 행 데이터로 변환 (컬럼 매핑 포함) // V2Repeater는 자체 데이터 관리 (아이템 선택 모달, useCustomTable 로딩, DataReceiver)를 사용.
const groupedDataProcessedRef = useRef(false); // EditModal의 groupedData는 메인 테이블 레코드이므로 V2Repeater에서는 사용하지 않음.
useEffect(() => {
if (!groupedData || !Array.isArray(groupedData) || groupedData.length === 0) return;
if (groupedDataProcessedRef.current) return;
groupedDataProcessedRef.current = true;
const newRows = groupedData.map((item: any, index: number) => {
const row: any = { _id: `grouped_${Date.now()}_${index}` };
for (const col of config.columns) {
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 ?? "";
row[`_display_${col.key}`] = sourceValue ?? "";
} else if (col.autoFill && col.autoFill.type !== "none") {
const autoValue = generateAutoFillValueSync(col, index, parentFormData);
if (autoValue !== undefined) {
row[col.key] = autoValue;
} else {
row[col.key] = "";
}
} else if (sourceValue !== undefined) {
row[col.key] = sourceValue;
} else {
row[col.key] = "";
}
}
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
}, [groupedData, config.columns, generateAutoFillValueSync]);
// parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신 // parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신
useEffect(() => { useEffect(() => {

View File

@ -80,7 +80,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{options {options
.filter((option) => option.value !== "") .filter((option) => option.value != null && option.value !== "")
.map((option) => ( .map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
@ -112,6 +112,12 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
} }
// 검색 가능 또는 다중 선택 → Combobox 사용 // 검색 가능 또는 다중 선택 → Combobox 사용
// null/undefined value를 가진 옵션 필터링 (cmdk가 value={null}일 때 크래시 발생)
const safeOptions = useMemo(() =>
options.filter((o) => o.value != null && o.value !== ""),
[options]
);
const selectedValues = useMemo(() => { const selectedValues = useMemo(() => {
if (!value) return []; if (!value) return [];
return Array.isArray(value) ? value : [value]; return Array.isArray(value) ? value : [value];
@ -119,9 +125,9 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
const selectedLabels = useMemo(() => { const selectedLabels = useMemo(() => {
return selectedValues return selectedValues
.map((v) => options.find((o) => o.value === v)?.label) .map((v) => safeOptions.find((o) => o.value === v)?.label)
.filter(Boolean) as string[]; .filter(Boolean) as string[];
}, [selectedValues, options]); }, [selectedValues, safeOptions]);
const handleSelect = useCallback((selectedValue: string) => { const handleSelect = useCallback((selectedValue: string) => {
if (multiple) { if (multiple) {
@ -191,7 +197,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
<Command <Command
filter={(itemValue, search) => { filter={(itemValue, search) => {
if (!search) return 1; if (!search) return 1;
const option = options.find((o) => o.value === itemValue); const option = safeOptions.find((o) => o.value === itemValue);
const label = (option?.label || option?.value || "").toLowerCase(); const label = (option?.label || option?.value || "").toLowerCase();
if (label.includes(search.toLowerCase())) return 1; if (label.includes(search.toLowerCase())) return 1;
return 0; return 0;
@ -201,7 +207,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
<CommandList> <CommandList>
<CommandEmpty> .</CommandEmpty> <CommandEmpty> .</CommandEmpty>
<CommandGroup> <CommandGroup>
{options.map((option) => { {safeOptions.map((option) => {
const displayLabel = option.label || option.value || "(빈 값)"; const displayLabel = option.label || option.value || "(빈 값)";
return ( return (
<CommandItem <CommandItem
@ -869,7 +875,11 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
} }
} }
setOptions(fetchedOptions); // null/undefined value 필터링 (cmdk 크래시 방지)
const sanitized = fetchedOptions.filter(
(o) => o.value != null && String(o.value) !== ""
).map((o) => ({ ...o, value: String(o.value), label: o.label || String(o.value) }));
setOptions(sanitized);
setOptionsLoaded(true); setOptionsLoaded(true);
} catch (error) { } catch (error) {
console.error("옵션 로딩 실패:", error); console.error("옵션 로딩 실패:", error);
@ -882,6 +892,42 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
loadOptions(); loadOptions();
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]); }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]);
// 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응)
const resolvedValue = useMemo(() => {
if (!value || options.length === 0) return value;
const resolveOne = (v: string): string => {
if (options.some(o => o.value === v)) return v;
const trimmed = v.trim();
const match = options.find(o => {
const cleanLabel = o.label.replace(/^[\s└]+/, '').trim();
return cleanLabel === trimmed;
});
return match ? match.value : v;
};
if (Array.isArray(value)) {
const resolved = value.map(resolveOne);
return resolved.every((v, i) => v === value[i]) ? value : resolved;
}
// 콤마 구분 복합값 처리 (e.g., "구매품,판매품,CAT_xxx")
if (typeof value === "string" && value.includes(",")) {
const parts = value.split(",");
const resolved = parts.map(p => resolveOne(p.trim()));
const result = resolved.join(",");
return result === value ? value : result;
}
return resolveOne(value);
}, [value, options]);
// 정규화 결과가 원본과 다르면 onChange로 자동 업데이트 (저장 시 코드 변환)
useEffect(() => {
if (!onChange || options.length === 0 || !value || value === resolvedValue) return;
onChange(resolvedValue as string | string[]);
}, [resolvedValue]); // eslint-disable-line react-hooks/exhaustive-deps
// 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지 // 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지
const autoFillTargets = useMemo(() => { const autoFillTargets = useMemo(() => {
if (source !== "entity" || !entityTable || !allComponents) return []; if (source !== "entity" || !entityTable || !allComponents) return [];
@ -1007,7 +1053,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return ( return (
<DropdownSelect <DropdownSelect
options={options} options={options}
value={value} value={resolvedValue}
onChange={handleChangeWithAutoFill} onChange={handleChangeWithAutoFill}
placeholder="선택" placeholder="선택"
searchable={config.mode === "combobox" ? true : config.searchable} searchable={config.mode === "combobox" ? true : config.searchable}
@ -1023,7 +1069,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return ( return (
<RadioSelect <RadioSelect
options={options} options={options}
value={typeof value === "string" ? value : value?.[0]} value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
onChange={(v) => handleChangeWithAutoFill(v)} onChange={(v) => handleChangeWithAutoFill(v)}
disabled={isDisabled} disabled={isDisabled}
/> />
@ -1034,7 +1080,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return ( return (
<CheckSelect <CheckSelect
options={options} options={options}
value={Array.isArray(value) ? value : value ? [value] : []} value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill} onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect} maxSelect={config.maxSelect}
disabled={isDisabled} disabled={isDisabled}
@ -1045,7 +1091,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return ( return (
<TagSelect <TagSelect
options={options} options={options}
value={Array.isArray(value) ? value : value ? [value] : []} value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill} onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect} maxSelect={config.maxSelect}
disabled={isDisabled} disabled={isDisabled}
@ -1056,7 +1102,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return ( return (
<TagboxSelect <TagboxSelect
options={options} options={options}
value={Array.isArray(value) ? value : value ? [value] : []} value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill} onChange={handleChangeWithAutoFill}
placeholder={config.placeholder || "선택하세요"} placeholder={config.placeholder || "선택하세요"}
maxSelect={config.maxSelect} maxSelect={config.maxSelect}
@ -1069,7 +1115,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return ( return (
<ToggleSelect <ToggleSelect
options={options} options={options}
value={typeof value === "string" ? value : value?.[0]} value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
onChange={(v) => handleChangeWithAutoFill(v)} onChange={(v) => handleChangeWithAutoFill(v)}
disabled={isDisabled} disabled={isDisabled}
/> />
@ -1079,7 +1125,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return ( return (
<SwapSelect <SwapSelect
options={options} options={options}
value={Array.isArray(value) ? value : value ? [value] : []} value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill} onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect} maxSelect={config.maxSelect}
disabled={isDisabled} disabled={isDisabled}
@ -1090,7 +1136,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return ( return (
<DropdownSelect <DropdownSelect
options={options} options={options}
value={value} value={resolvedValue}
onChange={handleChangeWithAutoFill} onChange={handleChangeWithAutoFill}
disabled={isDisabled} disabled={isDisabled}
style={heightStyle} style={heightStyle}
@ -1103,17 +1149,19 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정) // 라벨 위치 및 높이 계산
const labelPos = style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const labelGapValue = style?.labelGap || "8px";
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일) // 커스텀 스타일 감지
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border); const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor; const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius; const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (CSS 상속)
const customTextStyle: React.CSSProperties = {}; const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color; if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize; if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
@ -1121,6 +1169,58 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0; const hasCustomText = Object.keys(customTextStyle).length > 0;
const labelElement = showLabel ? (
<Label
htmlFor={id}
style={{
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
) : null;
const selectContent = (
<div
className={cn(
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
hasCustomBorder && "[&_button]:border-0! **:data-[slot=select-trigger]:border-0! [&_.border]:border-0!",
(hasCustomBorder || hasCustomRadius) && "[&_button]:rounded-none! **:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none!",
hasCustomBackground && "[&_button]:bg-transparent! **:data-[slot=select-trigger]:bg-transparent!",
)}
style={{ ...(hasCustomText ? customTextStyle : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
>
{renderSelect()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
className={cn(isDesignMode && "pointer-events-none")}
style={{
width: componentWidth,
height: componentHeight,
display: "flex",
flexDirection: labelPos === "left" ? "row" : "row-reverse",
alignItems: "center",
gap: labelGapValue,
}}
>
{labelElement}
{selectContent}
</div>
);
}
return ( return (
<div <div
ref={ref} ref={ref}
@ -1131,38 +1231,8 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} {labelElement}
{showLabel && ( {selectContent}
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div
className={cn(
"h-full w-full",
// 커스텀 테두리 설정 시, 내부 select trigger의 기본 테두리 제거
hasCustomBorder && "[&_button]:border-0! **:data-[slot=select-trigger]:border-0! [&_.border]:border-0!",
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거
(hasCustomBorder || hasCustomRadius) && "[&_button]:rounded-none! **:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none!",
// 커스텀 배경 설정 시, 내부 요소를 투명하게
hasCustomBackground && "[&_button]:bg-transparent! **:data-[slot=select-trigger]:bg-transparent!",
)}
style={hasCustomText ? customTextStyle : undefined}
>
{renderSelect()}
</div>
</div> </div>
); );
} }

View File

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

View File

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

View File

@ -356,9 +356,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 1. componentType이 "select-basic" 또는 "v2-select"인 경우 // 1. componentType이 "select-basic" 또는 "v2-select"인 경우
// 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등) // 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등)
const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode; const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode;
const isMultipleSelect = (component as any).componentConfig?.multiple;
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"]; const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
const isNonDropdownMode = componentMode && nonDropdownModes.includes(componentMode); const isNonDropdownMode = componentMode && nonDropdownModes.includes(componentMode);
const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode; const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect;
if ( if (
(inputType === "category" || webType === "category") && (inputType === "category" || webType === "category") &&
@ -545,10 +546,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
let currentValue; let currentValue;
if (componentType === "modal-repeater-table" || if (componentType === "modal-repeater-table" ||
componentType === "repeat-screen-modal" || componentType === "repeat-screen-modal" ||
componentType === "selected-items-detail-input" || componentType === "selected-items-detail-input") {
componentType === "v2-repeater") {
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용 // EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
currentValue = props.groupedData || formData?.[fieldName] || []; currentValue = props.groupedData || formData?.[fieldName] || [];
} else if (componentType === "v2-repeater") {
// V2Repeater는 자체 데이터 관리 (groupedData는 메인 테이블 레코드이므로 사용하지 않음)
currentValue = formData?.[fieldName] || [];
} else { } else {
currentValue = formData?.[fieldName] || ""; currentValue = formData?.[fieldName] || "";
} }

View File

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

View File

@ -568,6 +568,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (hasParentMapping) { if (hasParentMapping) {
try { try {
// 수정 모드 감지 (parentKeys 구성 전에 필요)
const urlParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
const urlEditMode = urlParams?.get("mode") === "edit";
const dataHasDbId = items.some(item => !!item.originalData?.id);
const isEditMode = urlEditMode || dataHasDbId;
// 부모 키 추출 (parentDataMapping에서) // 부모 키 추출 (parentDataMapping에서)
const parentKeys: Record<string, any> = {}; const parentKeys: Record<string, any> = {};
@ -581,11 +587,19 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
} }
componentConfig.parentDataMapping.forEach((mapping) => { componentConfig.parentDataMapping.forEach((mapping) => {
// 1차: formData(sourceData)에서 찾기 let value: any;
let value = getFieldValue(sourceData, mapping.sourceField);
// 수정 모드: originalData의 targetField 값 우선 사용
// 로드(editFilters)와 동일한 방식으로 FK 값을 가져와야
// 백엔드에서 기존 레코드를 정확히 매칭하여 UPDATE 수행 가능
if (isEditMode && items.length > 0 && items[0].originalData) {
value = items[0].originalData[mapping.targetField];
}
// 신규 모드 또는 originalData에 값 없으면 기존 로직
if (value === undefined || value === null) {
value = getFieldValue(sourceData, mapping.sourceField);
// 2차: formData에 없으면 dataRegistry[sourceTable]에서 찾기
// v2-split-panel-layout에서 좌측 항목 선택 시 dataRegistry에 저장한 데이터 활용
if ((value === undefined || value === null) && mapping.sourceTable) { if ((value === undefined || value === null) && mapping.sourceTable) {
const registryData = dataRegistry[mapping.sourceTable]; const registryData = dataRegistry[mapping.sourceTable];
if (registryData && registryData.length > 0) { if (registryData && registryData.length > 0) {
@ -593,6 +607,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
value = registryItem[mapping.sourceField]; value = registryItem[mapping.sourceField];
} }
} }
}
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
parentKeys[mapping.targetField] = value; parentKeys[mapping.targetField] = value;
@ -646,15 +661,6 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const additionalFields = componentConfig.additionalFields || []; const additionalFields = componentConfig.additionalFields || [];
const mainTable = componentConfig.targetTable!; const mainTable = componentConfig.targetTable!;
// 수정 모드 감지 (2가지 방법으로 확인)
// 1. URL에 mode=edit 파라미터 확인
// 2. 로드된 데이터에 DB id(PK)가 존재하는지 확인
// 수정 모드에서는 항상 deleteOrphans=true (기존 레코드 교체, 복제 방지)
const urlParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
const urlEditMode = urlParams?.get("mode") === "edit";
const dataHasDbId = items.some(item => !!item.originalData?.id);
const isEditMode = urlEditMode || dataHasDbId;
console.log("[SelectedItemsDetailInput] 수정 모드 감지:", { console.log("[SelectedItemsDetailInput] 수정 모드 감지:", {
urlEditMode, urlEditMode,
dataHasDbId, dataHasDbId,

View File

@ -86,6 +86,7 @@ interface ItemSearchModalProps {
onClose: () => void; onClose: () => void;
onSelect: (items: ItemInfo[]) => void; onSelect: (items: ItemInfo[]) => void;
companyCode?: string; companyCode?: string;
existingItemIds?: Set<string>;
} }
function ItemSearchModal({ function ItemSearchModal({
@ -93,6 +94,7 @@ function ItemSearchModal({
onClose, onClose,
onSelect, onSelect,
companyCode, companyCode,
existingItemIds,
}: ItemSearchModalProps) { }: ItemSearchModalProps) {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [items, setItems] = useState<ItemInfo[]>([]); const [items, setItems] = useState<ItemInfo[]>([]);
@ -182,7 +184,7 @@ function ItemSearchModal({
</div> </div>
) : ( ) : (
<table className="w-full text-xs sm:text-sm"> <table className="w-full text-xs sm:text-sm">
<thead className="bg-muted/50 sticky top-0"> <thead className="bg-muted sticky top-0 z-10">
<tr> <tr>
<th className="w-8 px-2 py-2 text-center"> <th className="w-8 px-2 py-2 text-center">
<Checkbox <Checkbox
@ -200,10 +202,13 @@ function ItemSearchModal({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{items.map((item) => ( {items.map((item) => {
const alreadyAdded = existingItemIds?.has(item.id) || false;
return (
<tr <tr
key={item.id} key={item.id}
onClick={() => { onClick={() => {
if (alreadyAdded) return;
setSelectedItems((prev) => { setSelectedItems((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); if (next.has(item.id)) next.delete(item.id);
@ -212,14 +217,19 @@ function ItemSearchModal({
}); });
}} }}
className={cn( className={cn(
"cursor-pointer border-t transition-colors", "border-t transition-colors",
selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent", alreadyAdded
? "cursor-not-allowed opacity-40"
: "cursor-pointer",
!alreadyAdded && selectedItems.has(item.id) ? "bg-primary/10" : !alreadyAdded ? "hover:bg-accent" : "",
)} )}
> >
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}> <td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox <Checkbox
checked={selectedItems.has(item.id)} checked={selectedItems.has(item.id)}
disabled={alreadyAdded}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (alreadyAdded) return;
setSelectedItems((prev) => { setSelectedItems((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (checked) next.add(item.id); if (checked) next.add(item.id);
@ -231,12 +241,14 @@ function ItemSearchModal({
</td> </td>
<td className="px-3 py-2 font-mono"> <td className="px-3 py-2 font-mono">
{item.item_number} {item.item_number}
{alreadyAdded && <span className="text-muted-foreground ml-1 text-[10px]">()</span>}
</td> </td>
<td className="px-3 py-2">{item.item_name}</td> <td className="px-3 py-2">{item.item_name}</td>
<td className="px-3 py-2">{item.type}</td> <td className="px-3 py-2">{item.type}</td>
<td className="px-3 py-2">{item.unit}</td> <td className="px-3 py-2">{item.unit}</td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
)} )}
@ -739,37 +751,40 @@ export function BomItemEditorComponent({
[originalNotifyChange, markChanged], [originalNotifyChange, markChanged],
); );
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
// EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장 // EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장
useEffect(() => { useEffect(() => {
if (isDesignMode || !bomId) return; if (isDesignMode || !bomId) return;
const handler = (e: Event) => { const handler = (e: Event) => {
const detail = (e as CustomEvent).detail; const detail = (e as CustomEvent).detail;
console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", { if (handleSaveAllRef.current) {
bomId,
treeDataLength: treeData.length,
hasRef: !!handleSaveAllRef.current,
});
if (treeData.length > 0 && handleSaveAllRef.current) {
const savePromise = handleSaveAllRef.current(); const savePromise = handleSaveAllRef.current();
if (detail?.pendingPromises) { if (detail?.pendingPromises) {
detail.pendingPromises.push(savePromise); detail.pendingPromises.push(savePromise);
console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료");
} }
} }
}; };
window.addEventListener("beforeFormSave", handler); window.addEventListener("beforeFormSave", handler);
console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode });
return () => window.removeEventListener("beforeFormSave", handler); return () => window.removeEventListener("beforeFormSave", handler);
}, [isDesignMode, bomId, treeData.length]); }, [isDesignMode, bomId]);
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
const handleSaveAll = useCallback(async () => { const handleSaveAll = useCallback(async () => {
if (!bomId) return; if (!bomId) return;
setSaving(true); setSaving(true);
try { try {
// 저장 시점에도 최신 version_id 조회 // version_id 확보: 없으면 서버에서 자동 초기화
const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId; let saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
if (!saveVersionId) {
try {
const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`);
if (initRes.data?.success && initRes.data.data?.versionId) {
saveVersionId = initRes.data.data.versionId;
}
} catch (e) {
console.error("[BomItemEditor] 버전 초기화 실패:", e);
}
}
const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => { const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => {
const result: any[] = []; const result: any[] = [];
@ -797,7 +812,7 @@ export function BomItemEditorComponent({
: null; : null;
if (node._isNew) { if (node._isNew) {
const payload: Record<string, any> = { const raw: Record<string, any> = {
...node.data, ...node.data,
[fkColumn]: bomId, [fkColumn]: bomId,
[parentKeyColumn]: realParentId, [parentKeyColumn]: realParentId,
@ -806,10 +821,16 @@ export function BomItemEditorComponent({
company_code: companyCode || undefined, company_code: companyCode || undefined,
version_id: saveVersionId || undefined, version_id: saveVersionId || undefined,
}; };
delete payload.id; // bom_detail에 유효한 필드만 남기기 (item_info 조인 필드 제거)
delete payload.tempId; const payload: Record<string, any> = {};
delete payload._isNew; const validKeys = new Set([
delete payload._isDeleted; fkColumn, parentKeyColumn, "seq_no", "level", "child_item_id",
"quantity", "unit", "loss_rate", "remark", "process_type",
"base_qty", "revision", "version_id", "company_code", "writer",
]);
Object.keys(raw).forEach((k) => {
if (validKeys.has(k)) payload[k] = raw[k];
});
const resp = await apiClient.post( const resp = await apiClient.post(
`/table-management/tables/${mainTableName}/add`, `/table-management/tables/${mainTableName}/add`,
@ -820,17 +841,14 @@ export function BomItemEditorComponent({
savedCount++; savedCount++;
} else if (node.id) { } else if (node.id) {
const updatedData: Record<string, any> = { const updatedData: Record<string, any> = {
...node.data,
id: node.id, id: node.id,
[fkColumn]: bomId,
[parentKeyColumn]: realParentId, [parentKeyColumn]: realParentId,
seq_no: String(seqNo), seq_no: String(seqNo),
level: String(level), level: String(level),
}; };
delete updatedData.tempId; ["quantity", "unit", "loss_rate", "remark", "process_type", "base_qty", "revision", "child_item_id", "version_id", "company_code"].forEach((k) => {
delete updatedData._isNew; if (node.data[k] !== undefined) updatedData[k] = node.data[k];
delete updatedData._isDeleted;
Object.keys(updatedData).forEach((k) => {
if (k.startsWith(`${sourceFk}_`)) delete updatedData[k];
}); });
await apiClient.put( await apiClient.put(
@ -919,6 +937,20 @@ export function BomItemEditorComponent({
setItemSearchOpen(true); setItemSearchOpen(true);
}, []); }, []);
// 이미 추가된 품목 ID 목록 (중복 방지용)
const existingItemIds = useMemo(() => {
const ids = new Set<string>();
const collect = (nodes: BomItemNode[]) => {
for (const n of nodes) {
const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"];
if (fk) ids.add(fk);
collect(n.children);
}
};
collect(treeData);
return ids;
}, [treeData, cfg]);
// 루트 품목 추가 시작 // 루트 품목 추가 시작
const handleAddRoot = useCallback(() => { const handleAddRoot = useCallback(() => {
setAddTargetParentId(null); setAddTargetParentId(null);
@ -1338,6 +1370,7 @@ export function BomItemEditorComponent({
onClose={() => setItemSearchOpen(false)} onClose={() => setItemSearchOpen(false)}
onSelect={handleItemSelect} onSelect={handleItemSelect}
companyCode={companyCode} companyCode={companyCode}
existingItemIds={existingItemIds}
/> />
</div> </div>
); );

View File

@ -13,6 +13,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
@ -35,6 +42,20 @@ export function BomDetailEditModal({
}: BomDetailEditModalProps) { }: BomDetailEditModalProps) {
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [processOptions, setProcessOptions] = useState<{ value: string; label: string }[]>([]);
useEffect(() => {
if (open && !isRootNode) {
apiClient.get("/table-categories/bom_detail/process_type/values")
.then((res) => {
const values = res.data?.data || [];
if (values.length > 0) {
setProcessOptions(values.map((v: any) => ({ value: v.value_code, label: v.value_label })));
}
})
.catch(() => { /* 카테고리 없으면 빈 배열 유지 */ });
}
}, [open, isRootNode]);
useEffect(() => { useEffect(() => {
if (node && open) { if (node && open) {
@ -47,9 +68,7 @@ export function BomDetailEditModal({
} else { } else {
setFormData({ setFormData({
quantity: node.quantity || "", quantity: node.quantity || "",
unit: node.unit || node.detail_unit || "",
process_type: node.process_type || "", process_type: node.process_type || "",
base_qty: node.base_qty || "",
loss_rate: node.loss_rate || "", loss_rate: node.loss_rate || "",
remark: node.remark || "", remark: node.remark || "",
}); });
@ -67,11 +86,15 @@ export function BomDetailEditModal({
try { try {
const targetTable = isRootNode ? "bom" : tableName; const targetTable = isRootNode ? "bom" : tableName;
const realId = isRootNode ? node.id?.replace("__root_", "") : node.id; const realId = isRootNode ? node.id?.replace("__root_", "") : node.id;
await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData); await apiClient.put(`/table-management/tables/${targetTable}/edit`, {
originalData: { id: realId },
updatedData: { id: realId, ...formData },
});
onSaved?.(); onSaved?.();
onOpenChange(false); onOpenChange(false);
} catch (error) { } catch (error) {
console.error("[BomDetailEdit] 저장 실패:", error); console.error("[BomDetailEdit] 저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -126,11 +149,19 @@ export function BomDetailEditModal({
</div> </div>
<div> <div>
<Label className="text-xs sm:text-sm"></Label> <Label className="text-xs sm:text-sm"></Label>
{isRootNode ? (
<Input <Input
value={formData.unit} value={formData.unit}
onChange={(e) => handleChange("unit", e.target.value)} onChange={(e) => handleChange("unit", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/> />
) : (
<Input
value={node?.child_unit || node?.unit || "-"}
disabled
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
)}
</div> </div>
</div> </div>
@ -139,12 +170,28 @@ export function BomDetailEditModal({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label className="text-xs sm:text-sm"></Label> <Label className="text-xs sm:text-sm"></Label>
{processOptions.length > 0 ? (
<Select
value={formData.process_type || ""}
onValueChange={(v) => handleChange("process_type", v)}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="공정 선택" />
</SelectTrigger>
<SelectContent>
{processOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input <Input
value={formData.process_type} value={formData.process_type}
onChange={(e) => handleChange("process_type", e.target.value)} onChange={(e) => handleChange("process_type", e.target.value)}
placeholder="예: 조립공정" placeholder="예: 조립공정"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/> />
)}
</div> </div>
<div> <div>
<Label className="text-xs sm:text-sm"> (%)</Label> <Label className="text-xs sm:text-sm"> (%)</Label>

View File

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

View File

@ -14,6 +14,7 @@ import {
History, History,
GitBranch, GitBranch,
Check, Check,
FileSpreadsheet,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -22,6 +23,7 @@ import { Button } from "@/components/ui/button";
import { BomDetailEditModal } from "./BomDetailEditModal"; import { BomDetailEditModal } from "./BomDetailEditModal";
import { BomHistoryModal } from "./BomHistoryModal"; import { BomHistoryModal } from "./BomHistoryModal";
import { BomVersionModal } from "./BomVersionModal"; import { BomVersionModal } from "./BomVersionModal";
import { BomExcelUploadModal } from "./BomExcelUploadModal";
interface BomTreeNode { interface BomTreeNode {
id: string; id: string;
@ -77,6 +79,7 @@ export function BomTreeComponent({
const [editTargetNode, setEditTargetNode] = useState<BomTreeNode | null>(null); const [editTargetNode, setEditTargetNode] = useState<BomTreeNode | null>(null);
const [historyModalOpen, setHistoryModalOpen] = useState(false); const [historyModalOpen, setHistoryModalOpen] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false); const [versionModalOpen, setVersionModalOpen] = useState(false);
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [colWidths, setColWidths] = useState<Record<string, number>>({}); const [colWidths, setColWidths] = useState<Record<string, number>>({});
const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => { const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => {
@ -138,6 +141,23 @@ export function BomTreeComponent({
const showHistory = features.showHistory !== false; const showHistory = features.showHistory !== false;
const showVersion = features.showVersion !== false; const showVersion = features.showVersion !== false;
// 카테고리 라벨 캐시 (process_type 등)
const [categoryLabels, setCategoryLabels] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const loadLabels = async () => {
try {
const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values`);
const vals = res.data?.data || [];
if (vals.length > 0) {
const map: Record<string, string> = {};
vals.forEach((v: any) => { map[v.value_code] = v.value_label; });
setCategoryLabels((prev) => ({ ...prev, process_type: map }));
}
} catch { /* 무시 */ }
};
loadLabels();
}, [detailTable]);
// ─── 데이터 로드 ─── // ─── 데이터 로드 ───
// BOM 헤더 데이터로 가상 0레벨 루트 노드 생성 // BOM 헤더 데이터로 가상 0레벨 루트 노드 생성
@ -168,7 +188,18 @@ export function BomTreeComponent({
setLoading(true); setLoading(true);
try { try {
const searchFilter: Record<string, any> = { [foreignKey]: bomId }; const searchFilter: Record<string, any> = { [foreignKey]: bomId };
const versionId = headerData?.current_version_id; let versionId = headerData?.current_version_id;
// version_id가 없으면 서버에서 자동 초기화
if (!versionId) {
try {
const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`);
if (initRes.data?.success && initRes.data.data?.versionId) {
versionId = initRes.data.data.versionId;
}
} catch { /* 무시 */ }
}
if (versionId) { if (versionId) {
searchFilter.version_id = versionId; searchFilter.version_id = versionId;
} }
@ -263,6 +294,7 @@ export function BomTreeComponent({
item_name: raw.item_name || "", item_name: raw.item_name || "",
item_code: raw.item_number || raw.item_code || "", item_code: raw.item_number || raw.item_code || "",
item_type: raw.item_type || raw.division || "", item_type: raw.item_type || raw.division || "",
unit: raw.unit || raw.item_unit || "",
} as BomHeaderInfo; } as BomHeaderInfo;
} }
} catch (e) { } catch (e) {
@ -348,6 +380,18 @@ export function BomTreeComponent({
detail.editData[key] = (headerInfo as any)[key]; detail.editData[key] = (headerInfo as any)[key];
} }
}); });
// entity join된 필드를 dot notation으로도 매핑 (item_info.xxx 형식)
const h = headerInfo as Record<string, any>;
if (h.item_name) detail.editData["item_info.item_name"] = h.item_name;
if (h.item_type) detail.editData["item_info.division"] = h.item_type;
if (h.item_code || h.item_number) detail.editData["item_info.item_number"] = h.item_code || h.item_number;
if (h.unit) detail.editData["item_info.unit"] = h.unit;
// entity join alias 형식도 매핑
if (h.item_name) detail.editData["item_id_item_name"] = h.item_name;
if (h.item_type) detail.editData["item_id_division"] = h.item_type;
if (h.item_code || h.item_number) detail.editData["item_id_item_number"] = h.item_code || h.item_number;
if (h.unit) detail.editData["item_id_unit"] = h.unit;
}; };
// capture: true → EditModal 리스너(bubble)보다 반드시 먼저 실행 // capture: true → EditModal 리스너(bubble)보다 반드시 먼저 실행
window.addEventListener("openEditModal", handler, true); window.addEventListener("openEditModal", handler, true);
@ -461,6 +505,11 @@ export function BomTreeComponent({
return <span className="font-medium text-gray-900">{value || "-"}</span>; return <span className="font-medium text-gray-900">{value || "-"}</span>;
} }
if (col.key === "status") {
const statusMap: Record<string, string> = { active: "사용", inactive: "미사용", developing: "개발중" };
return <span>{statusMap[String(value)] || value || "-"}</span>;
}
if (col.key === "quantity" || col.key === "base_qty") { if (col.key === "quantity" || col.key === "base_qty") {
return ( return (
<span className="font-medium tabular-nums text-gray-800"> <span className="font-medium tabular-nums text-gray-800">
@ -469,6 +518,11 @@ export function BomTreeComponent({
); );
} }
if (col.key === "process_type" && value) {
const label = categoryLabels.process_type?.[String(value)] || String(value);
return <span>{label}</span>;
}
if (col.key === "loss_rate") { if (col.key === "loss_rate") {
const num = Number(value); const num = Number(value);
if (!num) return <span className="text-gray-300">-</span>; if (!num) return <span className="text-gray-300">-</span>;
@ -786,6 +840,15 @@ export function BomTreeComponent({
</Button> </Button>
)} )}
<Button
variant="outline"
size="sm"
onClick={() => setExcelUploadOpen(true)}
className="h-6 gap-1 px-2 text-[10px]"
>
<FileSpreadsheet className="h-3 w-3" />
</Button>
<div className="mx-1 h-4 w-px bg-gray-200" /> <div className="mx-1 h-4 w-px bg-gray-200" />
<div className="flex overflow-hidden rounded-md border"> <div className="flex overflow-hidden rounded-md border">
<button <button
@ -1098,6 +1161,18 @@ export function BomTreeComponent({
}} }}
/> />
)} )}
{selectedBomId && (
<BomExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
bomId={selectedBomId}
bomName={headerInfo?.item_name || ""}
onSuccess={() => {
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
/>
)}
</div> </div>
); );
} }

View File

@ -43,6 +43,8 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [actionId, setActionId] = useState<string | null>(null); const [actionId, setActionId] = useState<string | null>(null);
const [newVersionName, setNewVersionName] = useState("");
const [showNewInput, setShowNewInput] = useState(false);
useEffect(() => { useEffect(() => {
if (open && bomId) loadVersions(); if (open && bomId) loadVersions();
@ -63,11 +65,26 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
const handleCreateVersion = async () => { const handleCreateVersion = async () => {
if (!bomId) return; if (!bomId) return;
const trimmed = newVersionName.trim();
if (!trimmed) {
alert("버전명을 입력해주세요.");
return;
}
setCreating(true); setCreating(true);
try { try {
const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable }); const res = await apiClient.post(`/bom/${bomId}/versions`, {
if (res.data?.success) loadVersions(); tableName, detailTable, versionName: trimmed,
} catch (error) { });
if (res.data?.success) {
setNewVersionName("");
setShowNewInput(false);
loadVersions();
} else {
alert(res.data?.message || "버전 생성 실패");
}
} catch (error: any) {
const msg = error.response?.data?.message || "버전 생성 실패";
alert(msg);
console.error("[BomVersion] 생성 실패:", error); console.error("[BomVersion] 생성 실패:", error);
} finally { } finally {
setCreating(false); setCreating(false);
@ -230,15 +247,46 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
)} )}
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> {showNewInput && (
<div className="flex items-center gap-2 border-t pt-3">
<input
type="text"
value={newVersionName}
onChange={(e) => setNewVersionName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateVersion()}
placeholder="버전명 입력 (예: 2.0, B, 개선판)"
className="h-8 flex-1 rounded-md border px-3 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-10 sm:text-sm"
autoFocus
/>
<Button <Button
onClick={handleCreateVersion} onClick={handleCreateVersion}
disabled={creating} disabled={creating}
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : "생성"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setShowNewInput(false); setNewVersionName(""); }}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
</Button>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
{!showNewInput && (
<Button
onClick={() => setShowNewInput(true)}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm" 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" />} <Plus className="mr-1 h-4 w-4" />
</Button> </Button>
)}
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}

View File

@ -42,8 +42,8 @@ export function ProcessWorkStandardComponent({
items, items,
routings, routings,
workItems, workItems,
selectedWorkItemDetails, selectedWorkItemIdByPhase,
selectedWorkItemId, selectedDetailsByPhase,
selection, selection,
loading, loading,
fetchItems, fetchItems,
@ -105,8 +105,8 @@ export function ProcessWorkStandardComponent({
); );
const handleSelectWorkItem = useCallback( const handleSelectWorkItem = useCallback(
(workItemId: string) => { (workItemId: string, phaseKey: string) => {
fetchWorkItemDetails(workItemId); fetchWorkItemDetails(workItemId, phaseKey);
}, },
[fetchWorkItemDetails] [fetchWorkItemDetails]
); );
@ -191,8 +191,8 @@ export function ProcessWorkStandardComponent({
key={phase.key} key={phase.key}
phase={phase} phase={phase}
items={workItemsByPhase[phase.key] || []} items={workItemsByPhase[phase.key] || []}
selectedWorkItemId={selectedWorkItemId} selectedWorkItemId={selectedWorkItemIdByPhase[phase.key] || null}
selectedWorkItemDetails={selectedWorkItemDetails} selectedWorkItemDetails={selectedDetailsByPhase[phase.key] || []}
detailTypes={config.detailTypes} detailTypes={config.detailTypes}
readonly={config.readonly} readonly={config.readonly}
onSelectWorkItem={handleSelectWorkItem} onSelectWorkItem={handleSelectWorkItem}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -61,11 +61,24 @@ export function WorkItemAddModal({
detailTypes, detailTypes,
editItem, editItem,
}: WorkItemAddModalProps) { }: WorkItemAddModalProps) {
const [title, setTitle] = useState(editItem?.title || ""); const [title, setTitle] = useState("");
const [isRequired, setIsRequired] = useState(editItem?.is_required || "Y"); const [isRequired, setIsRequired] = useState("Y");
const [description, setDescription] = useState(editItem?.description || ""); const [description, setDescription] = useState("");
const [details, setDetails] = useState<ModalDetail[]>([]); const [details, setDetails] = useState<ModalDetail[]>([]);
useEffect(() => {
if (open && editItem) {
setTitle(editItem.title || "");
setIsRequired(editItem.is_required || "Y");
setDescription(editItem.description || "");
} else if (open && !editItem) {
setTitle("");
setIsRequired("Y");
setDescription("");
setDetails([]);
}
}, [open, editItem]);
const resetForm = () => { const resetForm = () => {
setTitle(""); setTitle("");
setIsRequired("Y"); setIsRequired("Y");

View File

@ -20,13 +20,13 @@ interface WorkPhaseSectionProps {
selectedWorkItemDetails: WorkItemDetail[]; selectedWorkItemDetails: WorkItemDetail[];
detailTypes: DetailTypeDefinition[]; detailTypes: DetailTypeDefinition[];
readonly?: boolean; readonly?: boolean;
onSelectWorkItem: (workItemId: string) => void; onSelectWorkItem: (workItemId: string, phaseKey: string) => void;
onAddWorkItem: (phase: string) => void; onAddWorkItem: (phase: string) => void;
onEditWorkItem: (item: WorkItem) => void; onEditWorkItem: (item: WorkItem) => void;
onDeleteWorkItem: (id: string) => void; onDeleteWorkItem: (id: string) => void;
onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>) => void; onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>, phaseKey: string) => void;
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void; onUpdateDetail: (id: string, data: Partial<WorkItemDetail>, phaseKey: string) => void;
onDeleteDetail: (id: string) => void; onDeleteDetail: (id: string, phaseKey: string) => void;
} }
export function WorkPhaseSection({ export function WorkPhaseSection({
@ -45,9 +45,6 @@ export function WorkPhaseSection({
onDeleteDetail, onDeleteDetail,
}: WorkPhaseSectionProps) { }: WorkPhaseSectionProps) {
const selectedItem = items.find((i) => i.id === selectedWorkItemId) || null; const selectedItem = items.find((i) => i.id === selectedWorkItemId) || null;
const isThisSectionSelected = items.some(
(i) => i.id === selectedWorkItemId
);
return ( return (
<div className="rounded-lg border bg-card"> <div className="rounded-lg border bg-card">
@ -94,7 +91,7 @@ export function WorkPhaseSection({
item={item} item={item}
isSelected={selectedWorkItemId === item.id} isSelected={selectedWorkItemId === item.id}
readonly={readonly} readonly={readonly}
onClick={() => onSelectWorkItem(item.id)} onClick={() => onSelectWorkItem(item.id, phase.key)}
onEdit={() => onEditWorkItem(item)} onEdit={() => onEditWorkItem(item)}
onDelete={() => onDeleteWorkItem(item.id)} onDelete={() => onDeleteWorkItem(item.id)}
/> />
@ -106,15 +103,15 @@ export function WorkPhaseSection({
{/* 우측: 상세 리스트 */} {/* 우측: 상세 리스트 */}
<div className="flex-1"> <div className="flex-1">
<WorkItemDetailList <WorkItemDetailList
workItem={isThisSectionSelected ? selectedItem : null} workItem={selectedItem}
details={isThisSectionSelected ? selectedWorkItemDetails : []} details={selectedWorkItemDetails}
detailTypes={detailTypes} detailTypes={detailTypes}
readonly={readonly} readonly={readonly}
onCreateDetail={(data) => onCreateDetail={(data) =>
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data) selectedWorkItemId && onCreateDetail(selectedWorkItemId, data, phase.key)
} }
onUpdateDetail={onUpdateDetail} onUpdateDetail={(id, data) => onUpdateDetail(id, data, phase.key)}
onDeleteDetail={onDeleteDetail} onDeleteDetail={(id) => onDeleteDetail(id, phase.key)}
/> />
</div> </div>
</div> </div>

View File

@ -27,7 +27,6 @@ export const defaultConfig: ProcessWorkStandardConfig = {
{ value: "inspect", label: "검사항목" }, { value: "inspect", label: "검사항목" },
{ value: "procedure", label: "작업절차" }, { value: "procedure", label: "작업절차" },
{ value: "input", label: "직접입력" }, { value: "input", label: "직접입력" },
{ value: "info", label: "정보조회" },
], ],
splitRatio: 30, splitRatio: 30,
leftPanelTitle: "품목 및 공정 선택", leftPanelTitle: "품목 및 공정 선택",

View File

@ -17,8 +17,9 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
const [items, setItems] = useState<ItemData[]>([]); const [items, setItems] = useState<ItemData[]>([]);
const [routings, setRoutings] = useState<RoutingVersion[]>([]); const [routings, setRoutings] = useState<RoutingVersion[]>([]);
const [workItems, setWorkItems] = useState<WorkItem[]>([]); const [workItems, setWorkItems] = useState<WorkItem[]>([]);
const [selectedWorkItemDetails, setSelectedWorkItemDetails] = useState<WorkItemDetail[]>([]); // 섹션(phase)별 독립적인 선택 상태 관리
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null); const [selectedWorkItemIdByPhase, setSelectedWorkItemIdByPhase] = useState<Record<string, string | null>>({});
const [selectedDetailsByPhase, setSelectedDetailsByPhase] = useState<Record<string, WorkItemDetail[]>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -101,15 +102,15 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
} }
}, []); }, []);
// 작업 항목 상세 조회 // 작업 항목 상세 조회 (phase별 독립 저장)
const fetchWorkItemDetails = useCallback(async (workItemId: string) => { const fetchWorkItemDetails = useCallback(async (workItemId: string, phaseKey: string) => {
try { try {
const res = await apiClient.get( const res = await apiClient.get(
`${API_BASE}/work-items/${workItemId}/details` `${API_BASE}/work-items/${workItemId}/details`
); );
if (res.data?.success) { if (res.data?.success) {
setSelectedWorkItemDetails(res.data.data); setSelectedDetailsByPhase(prev => ({ ...prev, [phaseKey]: res.data.data }));
setSelectedWorkItemId(workItemId); setSelectedWorkItemIdByPhase(prev => ({ ...prev, [phaseKey]: workItemId }));
} }
} catch (err) { } catch (err) {
console.error("상세 조회 실패", err); console.error("상세 조회 실패", err);
@ -129,8 +130,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
processName: null, processName: null,
})); }));
setWorkItems([]); setWorkItems([]);
setSelectedWorkItemDetails([]); setSelectedDetailsByPhase({});
setSelectedWorkItemId(null); setSelectedWorkItemIdByPhase({});
await fetchRoutings(itemCode); await fetchRoutings(itemCode);
}, },
[fetchRoutings] [fetchRoutings]
@ -151,8 +152,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
routingDetailId, routingDetailId,
processName, processName,
})); }));
setSelectedWorkItemDetails([]); setSelectedDetailsByPhase({});
setSelectedWorkItemId(null); setSelectedWorkItemIdByPhase({});
await fetchWorkItems(routingDetailId); await fetchWorkItems(routingDetailId);
}, },
[fetchWorkItems] [fetchWorkItems]
@ -233,28 +234,43 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
const res = await apiClient.delete(`${API_BASE}/work-items/${id}`); const res = await apiClient.delete(`${API_BASE}/work-items/${id}`);
if (res.data?.success && selection.routingDetailId) { if (res.data?.success && selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId); await fetchWorkItems(selection.routingDetailId);
if (selectedWorkItemId === id) { // 삭제된 항목이 선택되어 있던 phase의 선택 상태 초기화
setSelectedWorkItemDetails([]); setSelectedWorkItemIdByPhase(prev => {
setSelectedWorkItemId(null); const next = { ...prev };
for (const phaseKey of Object.keys(next)) {
if (next[phaseKey] === id) {
next[phaseKey] = null;
} }
} }
return next;
});
setSelectedDetailsByPhase(prev => {
const next = { ...prev };
for (const phaseKey of Object.keys(next)) {
if (selectedWorkItemIdByPhase[phaseKey] === id) {
next[phaseKey] = [];
}
}
return next;
});
}
} catch (err) { } catch (err) {
console.error("작업 항목 삭제 실패", err); console.error("작업 항목 삭제 실패", err);
} }
}, },
[selection.routingDetailId, selectedWorkItemId, fetchWorkItems] [selection.routingDetailId, selectedWorkItemIdByPhase, fetchWorkItems]
); );
// 상세 추가 // 상세 추가
const createDetail = useCallback( const createDetail = useCallback(
async (workItemId: string, data: Partial<WorkItemDetail>) => { async (workItemId: string, data: Partial<WorkItemDetail>, phaseKey: string) => {
try { try {
const res = await apiClient.post(`${API_BASE}/work-item-details`, { const res = await apiClient.post(`${API_BASE}/work-item-details`, {
work_item_id: workItemId, work_item_id: workItemId,
...data, ...data,
}); });
if (res.data?.success) { if (res.data?.success) {
await fetchWorkItemDetails(workItemId); await fetchWorkItemDetails(workItemId, phaseKey);
if (selection.routingDetailId) { if (selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId); await fetchWorkItems(selection.routingDetailId);
} }
@ -268,32 +284,36 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
// 상세 수정 // 상세 수정
const updateDetail = useCallback( const updateDetail = useCallback(
async (id: string, data: Partial<WorkItemDetail>) => { async (id: string, data: Partial<WorkItemDetail>, phaseKey: string) => {
try { try {
const res = await apiClient.put( const res = await apiClient.put(
`${API_BASE}/work-item-details/${id}`, `${API_BASE}/work-item-details/${id}`,
data data
); );
if (res.data?.success && selectedWorkItemId) { if (res.data?.success) {
await fetchWorkItemDetails(selectedWorkItemId); const workItemId = selectedWorkItemIdByPhase[phaseKey];
if (workItemId) {
await fetchWorkItemDetails(workItemId, phaseKey);
}
} }
} catch (err) { } catch (err) {
console.error("상세 수정 실패", err); console.error("상세 수정 실패", err);
} }
}, },
[selectedWorkItemId, fetchWorkItemDetails] [selectedWorkItemIdByPhase, fetchWorkItemDetails]
); );
// 상세 삭제 // 상세 삭제
const deleteDetail = useCallback( const deleteDetail = useCallback(
async (id: string) => { async (id: string, phaseKey: string) => {
try { try {
const res = await apiClient.delete( const res = await apiClient.delete(
`${API_BASE}/work-item-details/${id}` `${API_BASE}/work-item-details/${id}`
); );
if (res.data?.success) { if (res.data?.success) {
if (selectedWorkItemId) { const workItemId = selectedWorkItemIdByPhase[phaseKey];
await fetchWorkItemDetails(selectedWorkItemId); if (workItemId) {
await fetchWorkItemDetails(workItemId, phaseKey);
} }
if (selection.routingDetailId) { if (selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId); await fetchWorkItems(selection.routingDetailId);
@ -304,7 +324,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
} }
}, },
[ [
selectedWorkItemId, selectedWorkItemIdByPhase,
selection.routingDetailId, selection.routingDetailId,
fetchWorkItemDetails, fetchWorkItemDetails,
fetchWorkItems, fetchWorkItems,
@ -315,8 +335,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
items, items,
routings, routings,
workItems, workItems,
selectedWorkItemDetails, selectedWorkItemIdByPhase,
selectedWorkItemId, selectedDetailsByPhase,
selection, selection,
loading, loading,
saving, saving,
@ -325,7 +345,6 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
selectProcess, selectProcess,
fetchWorkItems, fetchWorkItems,
fetchWorkItemDetails, fetchWorkItemDetails,
setSelectedWorkItemId,
createWorkItem, createWorkItem,
updateWorkItem, updateWorkItem,
deleteWorkItem, deleteWorkItem,

View File

@ -20,6 +20,7 @@ import {
Trash2, Trash2,
Settings, Settings,
Move, Move,
FileSpreadsheet,
} from "lucide-react"; } from "lucide-react";
import { dataApi } from "@/lib/api/data"; import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
@ -43,6 +44,7 @@ import { useSplitPanel } from "./SplitPanelContext";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { PanelInlineComponent } from "./types"; import { PanelInlineComponent } from "./types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props // 추가 props
@ -500,6 +502,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [showAddModal, setShowAddModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false);
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null); const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({}); const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false);
// 수정 모달 상태 // 수정 모달 상태
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
@ -3010,6 +3013,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<CardTitle className="text-base font-semibold"> <CardTitle className="text-base font-semibold">
{componentConfig.leftPanel?.title || "좌측 패널"} {componentConfig.leftPanel?.title || "좌측 패널"}
</CardTitle> </CardTitle>
<div className="flex items-center gap-1">
{!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && (
<Button size="sm" variant="outline" onClick={() => setBomExcelUploadOpen(true)}>
<FileSpreadsheet className="mr-1 h-4 w-4" />
</Button>
)}
{!isDesignMode && componentConfig.leftPanel?.showAdd && ( {!isDesignMode && componentConfig.leftPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("left")}> <Button size="sm" variant="outline" onClick={() => handleAddClick("left")}>
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
@ -3017,6 +3027,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</Button> </Button>
)} )}
</div> </div>
</div>
</CardHeader> </CardHeader>
{componentConfig.leftPanel?.showSearch && ( {componentConfig.leftPanel?.showSearch && (
<div className="flex-shrink-0 border-b p-2"> <div className="flex-shrink-0 border-b p-2">
@ -3361,6 +3372,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
})); }));
// 🔧 그룹화된 데이터 렌더링 // 🔧 그룹화된 데이터 렌더링
const hasGroupedLeftActions = !isDesignMode && (
(componentConfig.leftPanel?.showEdit !== false) ||
(componentConfig.leftPanel?.showDelete !== false)
);
if (groupedLeftData.length > 0) { if (groupedLeftData.length > 0) {
return ( return (
<div className="overflow-auto"> <div className="overflow-auto">
@ -3385,6 +3400,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{col.label} {col.label}
</th> </th>
))} ))}
{hasGroupedLeftActions && (
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-gray-500 uppercase whitespace-nowrap" style={{ width: "80px" }}>
</th>
)}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
@ -3399,7 +3418,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tr <tr
key={itemId} key={itemId}
onClick={() => handleLeftItemSelect(item)} onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${ className={`group hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : "" isSelected ? "bg-primary/10" : ""
}`} }`}
> >
@ -3417,6 +3436,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)} )}
</td> </td>
))} ))}
{hasGroupedLeftActions && (
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{(componentConfig.leftPanel?.showEdit !== false) && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-gray-200"
>
<Pencil className="h-3.5 w-3.5 text-gray-500" />
</button>
)}
{(componentConfig.leftPanel?.showDelete !== false) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-red-100"
>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</button>
)}
</div>
</td>
)}
</tr> </tr>
); );
})} })}
@ -3429,6 +3476,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} }
// 🔧 일반 테이블 렌더링 (그룹화 없음) // 🔧 일반 테이블 렌더링 (그룹화 없음)
const hasLeftTableActions = !isDesignMode && (
(componentConfig.leftPanel?.showEdit !== false) ||
(componentConfig.leftPanel?.showDelete !== false)
);
return ( return (
<div className="overflow-auto"> <div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
@ -3447,6 +3498,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{col.label} {col.label}
</th> </th>
))} ))}
{hasLeftTableActions && (
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-gray-500 uppercase whitespace-nowrap" style={{ width: "80px" }}>
</th>
)}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
@ -3461,7 +3516,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tr <tr
key={itemId} key={itemId}
onClick={() => handleLeftItemSelect(item)} onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${ className={`group hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : "" isSelected ? "bg-primary/10" : ""
}`} }`}
> >
@ -3479,6 +3534,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)} )}
</td> </td>
))} ))}
{hasLeftTableActions && (
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{(componentConfig.leftPanel?.showEdit !== false) && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-gray-200"
>
<Pencil className="h-3.5 w-3.5 text-gray-500" />
</button>
)}
{(componentConfig.leftPanel?.showDelete !== false) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-red-100"
>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</button>
)}
</div>
</td>
)}
</tr> </tr>
); );
})} })}
@ -4998,6 +5081,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{(componentConfig.leftPanel as any)?.showBomExcelUpload && (
<BomExcelUploadModal
open={bomExcelUploadOpen}
onOpenChange={setBomExcelUploadOpen}
onSuccess={() => {
loadLeftData();
}}
/>
)}
</div> </div>
); );
}; };

View File

@ -2243,6 +2243,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글) // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글)
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
// 현재 편집 중인 셀을 클릭한 경우 포커스 이동 방지 (select 드롭다운 등이 닫히는 것 방지)
if (editingCell?.rowIndex === rowIndex && editingCell?.colIndex === colIndex) {
return;
}
setFocusedCell({ rowIndex, colIndex }); setFocusedCell({ rowIndex, colIndex });
tableContainerRef.current?.focus(); tableContainerRef.current?.focus();
@ -5462,23 +5468,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div> </div>
)} )}
{/* 선택 정보 */} {/* 선택 정보 - 숨김 처리 */}
{selectedRows.size > 0 && (
<div className="border-border flex items-center gap-1 border-r pr-2">
<span className="bg-primary/10 text-primary rounded px-2 py-0.5 text-xs">
{selectedRows.size}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedRows(new Set())}
className="h-6 w-6 p-0"
title="선택 해제"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{/* 🆕 통합 검색 패널 */} {/* 🆕 통합 검색 패널 */}
{(tableConfig.toolbar?.showSearch ?? false) && ( {(tableConfig.toolbar?.showSearch ?? false) && (

View File

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

View File

@ -1,18 +1,26 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { Delete } from "lucide-react"; import { Delete, Trash2, Plus, ArrowLeft } from "lucide-react";
import { import {
Dialog, Dialog,
DialogPortal, DialogPortal,
DialogOverlay, DialogOverlay,
DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { import {
PackageUnitModal, PackageUnitModal,
PACKAGE_UNITS, PACKAGE_UNITS,
type PackageUnit,
} from "./PackageUnitModal"; } from "./PackageUnitModal";
import type { CardPackageConfig, PackageEntry } from "../types";
type InputStep =
| "quantity" // 기본: 직접 수량 입력 (포장 OFF)
| "package_count" // 포장: 포장 수량 (N개)
| "quantity_per_unit" // 포장: 개당 수량 (M EA)
| "summary"; // 포장: 결과 확인 + 추가/완료
interface NumberInputModalProps { interface NumberInputModalProps {
open: boolean; open: boolean;
@ -22,7 +30,10 @@ interface NumberInputModalProps {
initialPackageUnit?: string; initialPackageUnit?: string;
min?: number; min?: number;
maxValue?: number; maxValue?: number;
onConfirm: (value: number, packageUnit?: string) => void; /** @deprecated packageConfig 사용 */
showPackageUnit?: boolean;
packageConfig?: CardPackageConfig;
onConfirm: (value: number, packageUnit?: string, packageEntries?: PackageEntry[]) => void;
} }
export function NumberInputModal({ export function NumberInputModal({
@ -33,51 +44,184 @@ export function NumberInputModal({
initialPackageUnit, initialPackageUnit,
min = 0, min = 0,
maxValue = 999999, maxValue = 999999,
showPackageUnit,
packageConfig,
onConfirm, onConfirm,
}: NumberInputModalProps) { }: NumberInputModalProps) {
const [displayValue, setDisplayValue] = useState(""); const [displayValue, setDisplayValue] = useState("");
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined); const [step, setStep] = useState<InputStep>("quantity");
const [isPackageModalOpen, setIsPackageModalOpen] = useState(false); const [isPackageModalOpen, setIsPackageModalOpen] = useState(false);
// 포장 2단계 플로우용 상태
const [selectedUnit, setSelectedUnit] = useState<{ id: string; label: string } | null>(null);
const [packageCount, setPackageCount] = useState(0);
const [entries, setEntries] = useState<PackageEntry[]>([]);
const isPackageEnabled = packageConfig?.enabled ?? showPackageUnit ?? true;
const showSummary = packageConfig?.showSummaryMessage !== false;
const entriesTotal = useMemo(
() => entries.reduce((sum, e) => sum + e.totalQuantity, 0),
[entries]
);
const remainingQuantity = Math.max(0, maxValue - entriesTotal);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setDisplayValue(initialValue > 0 ? String(initialValue) : ""); setDisplayValue(initialValue > 0 ? String(initialValue) : "");
setPackageUnit(initialPackageUnit); setStep("quantity");
setSelectedUnit(null);
setPackageCount(0);
setEntries([]);
} }
}, [open, initialValue, initialPackageUnit]); }, [open, initialValue]);
// --- 키패드 핸들러 ---
const currentMax = step === "quantity"
? maxValue
: step === "package_count"
? 9999
: step === "quantity_per_unit"
? Math.max(1, remainingQuantity)
: maxValue;
const handleNumberClick = (num: string) => { const handleNumberClick = (num: string) => {
const newStr = displayValue + num; const newStr = displayValue + num;
const numericValue = parseInt(newStr, 10); const numericValue = parseInt(newStr, 10);
setDisplayValue(numericValue > maxValue ? String(maxValue) : newStr); setDisplayValue(numericValue > currentMax ? String(currentMax) : newStr);
}; };
const handleBackspace = () => const handleBackspace = () =>
setDisplayValue((prev) => prev.slice(0, -1)); setDisplayValue((prev) => prev.slice(0, -1));
const handleClear = () => setDisplayValue(""); const handleClear = () => setDisplayValue("");
const handleMax = () => setDisplayValue(String(maxValue)); const handleMax = () => setDisplayValue(String(currentMax));
// --- 확인 버튼: step에 따라 다르게 동작 ---
const handleConfirm = () => { const handleConfirm = () => {
const numericValue = parseInt(displayValue, 10) || 0; const numericValue = parseInt(displayValue, 10) || 0;
if (step === "quantity") {
const finalValue = Math.max(min, Math.min(maxValue, numericValue)); const finalValue = Math.max(min, Math.min(maxValue, numericValue));
onConfirm(finalValue, packageUnit); onConfirm(finalValue, undefined, undefined);
onOpenChange(false);
return;
}
if (step === "package_count") {
if (numericValue <= 0) return;
setPackageCount(numericValue);
setDisplayValue("");
setStep("quantity_per_unit");
return;
}
if (step === "quantity_per_unit") {
if (numericValue <= 0 || !selectedUnit) return;
const total = Math.min(packageCount * numericValue, remainingQuantity);
const newEntry: PackageEntry = {
unitId: selectedUnit.id,
unitLabel: selectedUnit.label,
packageCount,
quantityPerUnit: numericValue,
totalQuantity: total,
};
setEntries((prev) => [...prev, newEntry]);
setDisplayValue("");
setStep("summary");
return;
}
};
// --- 포장 단위 선택 콜백 ---
const handlePackageUnitSelect = (unitId: string) => {
const matched = PACKAGE_UNITS.find((u) => u.value === unitId);
const matchedCustom = packageConfig?.customUnits?.find((cu) => cu.id === unitId);
const label = matched?.label ?? matchedCustom?.label ?? unitId;
setSelectedUnit({ id: unitId, label });
setDisplayValue("");
setStep("package_count");
};
// --- summary 액션 ---
const handleAddMore = () => {
setIsPackageModalOpen(true);
};
const handleRemoveEntry = (index: number) => {
setEntries((prev) => prev.filter((_, i) => i !== index));
};
const handleComplete = () => {
if (entries.length === 0) return;
const total = entries.reduce((sum, e) => sum + e.totalQuantity, 0);
const lastUnit = entries[entries.length - 1].unitId;
onConfirm(total, lastUnit, entries);
onOpenChange(false); onOpenChange(false);
}; };
const handlePackageUnitSelect = (selected: PackageUnit) => { const handleBack = () => {
setPackageUnit(selected); if (step === "package_count") {
setStep("quantity");
setSelectedUnit(null);
setDisplayValue("");
} else if (step === "quantity_per_unit") {
setStep("package_count");
setDisplayValue(String(packageCount));
} else if (step === "summary") {
if (entries.length > 0) {
const last = entries[entries.length - 1];
setEntries((prev) => prev.slice(0, -1));
setSelectedUnit({ id: last.unitId, label: last.unitLabel });
setPackageCount(last.packageCount);
setDisplayValue(String(last.quantityPerUnit));
setStep("quantity_per_unit");
} else {
setStep("quantity");
setDisplayValue("");
}
}
}; };
const matchedUnit = packageUnit // --- 안내 메시지 ---
? PACKAGE_UNITS.find((u) => u.value === packageUnit)
: null; const guideMessage = useMemo(() => {
const packageUnitLabel = matchedUnit?.label ?? null; switch (step) {
const packageUnitEmoji = matchedUnit?.emoji ?? "📦"; case "quantity":
return "수량을 입력하세요";
case "package_count":
return `${selectedUnit?.label || "포장"}을(를) 몇 개 사용하시나요?`;
case "quantity_per_unit":
return `${selectedUnit?.label || "포장"} 1개에 몇 ${unit} 넣으시나요?`;
case "summary":
return "";
default:
return "";
}
}, [step, selectedUnit, unit]);
// --- 헤더 정보 ---
const headerLabel = useMemo(() => {
if (step === "summary") {
return `등록: ${entriesTotal.toLocaleString()} ${unit} / 남은: ${remainingQuantity.toLocaleString()} ${unit}`;
}
if (entries.length > 0) {
return `남은 ${remainingQuantity.toLocaleString()} ${unit}`;
}
return `최대 ${maxValue.toLocaleString()} ${unit}`;
}, [step, entriesTotal, remainingQuantity, maxValue, unit, entries.length]);
const displayText = displayValue const displayText = displayValue
? parseInt(displayValue, 10).toLocaleString() ? parseInt(displayValue, 10).toLocaleString()
: ""; : "";
const isBackVisible = step !== "quantity";
return ( return (
<> <>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@ -86,21 +230,111 @@ export function NumberInputModal({
<DialogPrimitive.Content <DialogPrimitive.Content
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] w-full max-w-[95vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden border shadow-lg duration-200 sm:max-w-[360px] sm:rounded-lg" className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] w-full max-w-[95vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden border shadow-lg duration-200 sm:max-w-[360px] sm:rounded-lg"
> >
{/* 파란 헤더 */} <VisuallyHidden><DialogTitle> </DialogTitle></VisuallyHidden>
{/* 헤더 */}
<div className="flex items-center justify-between bg-blue-500 px-4 py-3"> <div className="flex items-center justify-between bg-blue-500 px-4 py-3">
<div className="flex items-center gap-2">
{isBackVisible && (
<button
type="button"
onClick={handleBack}
className="flex items-center justify-center rounded-full bg-white/20 p-1.5 text-white hover:bg-white/30 active:bg-white/40"
>
<ArrowLeft className="h-4 w-4" />
</button>
)}
<span className="rounded-full bg-blue-400/50 px-3 py-1 text-sm font-medium text-white"> <span className="rounded-full bg-blue-400/50 px-3 py-1 text-sm font-medium text-white">
{maxValue.toLocaleString()} {unit} {headerLabel}
</span> </span>
</div>
{isPackageEnabled && step === "quantity" && (
<button <button
type="button" type="button"
onClick={() => setIsPackageModalOpen(true)} 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" 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> </button>
)}
</div> </div>
<div className="space-y-3 p-4"> <div className="space-y-3 p-4">
{/* summary 단계: 포장 내역 리스트 */}
{step === "summary" ? (
<div className="space-y-3">
{/* 안내 메시지 - 마지막 등록 결과 */}
{showSummary && entries.length > 0 && (
<div className="rounded-lg bg-blue-50 px-3 py-2 text-center text-sm font-medium text-blue-700">
{(() => {
const last = entries[entries.length - 1];
return `${last.packageCount}${last.unitLabel} x ${last.quantityPerUnit}${unit} = ${last.totalQuantity.toLocaleString()}${unit}`;
})()}
</div>
)}
{/* 포장 내역 리스트 */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-gray-500"> </p>
{entries.map((entry, idx) => (
<div
key={idx}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
<span className="text-sm text-gray-700">
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}{unit} = {entry.totalQuantity.toLocaleString()}{unit}
</span>
<button
type="button"
onClick={() => handleRemoveEntry(idx)}
className="rounded-full p-1 text-gray-400 hover:bg-red-50 hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
{/* 합계 */}
<div className="flex items-center justify-between rounded-lg bg-green-50 px-3 py-2">
<span className="text-sm font-medium text-green-700"></span>
<span className="text-lg font-bold text-green-700">
{entriesTotal.toLocaleString()} {unit}
</span>
</div>
{/* 남은 수량 */}
{remainingQuantity > 0 && (
<div className="flex items-center justify-between rounded-lg bg-amber-50 px-3 py-2">
<span className="text-sm font-medium text-amber-700"> </span>
<span className="text-sm font-bold text-amber-700">
{remainingQuantity.toLocaleString()} {unit}
</span>
</div>
)}
{/* 액션 버튼 */}
<div className="grid grid-cols-2 gap-2">
{remainingQuantity > 0 && (
<button
type="button"
className="flex h-12 items-center justify-center gap-1.5 rounded-2xl border border-blue-200 bg-blue-50 text-sm font-bold text-blue-600 active:bg-blue-100"
onClick={handleAddMore}
>
<Plus className="h-4 w-4" />
</button>
)}
<button
type="button"
className={`flex h-12 items-center justify-center rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600 ${remainingQuantity <= 0 ? "col-span-2" : ""}`}
onClick={handleComplete}
>
</button>
</div>
</div>
) : (
<>
{/* 숫자 표시 영역 */} {/* 숫자 표시 영역 */}
<div className="flex min-h-[72px] items-center justify-end rounded-xl border-2 border-gray-200 bg-gray-50 px-4 py-4"> <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 ? ( {displayText ? (
@ -112,14 +346,13 @@ export function NumberInputModal({
)} )}
</div> </div>
{/* 안내 텍스트 */} {/* 단계별 안내 텍스트 */}
<p className="text-muted-foreground text-center text-sm"> <p className="text-muted-foreground text-center text-sm">
{guideMessage}
</p> </p>
{/* 키패드 4x4 */} {/* 키패드 4x4 */}
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
{/* 1행: 7 8 9 ← (주황) */}
{["7", "8", "9"].map((n) => ( {["7", "8", "9"].map((n) => (
<button <button
key={n} key={n}
@ -138,7 +371,6 @@ export function NumberInputModal({
<Delete className="h-5 w-5" /> <Delete className="h-5 w-5" />
</button> </button>
{/* 2행: 4 5 6 C (주황) */}
{["4", "5", "6"].map((n) => ( {["4", "5", "6"].map((n) => (
<button <button
key={n} key={n}
@ -157,7 +389,6 @@ export function NumberInputModal({
C C
</button> </button>
{/* 3행: 1 2 3 MAX (파란) */}
{["1", "2", "3"].map((n) => ( {["1", "2", "3"].map((n) => (
<button <button
key={n} key={n}
@ -176,7 +407,6 @@ export function NumberInputModal({
MAX MAX
</button> </button>
{/* 4행: 0 / 확인 (초록, 3칸) */}
<button <button
type="button" type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200" className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
@ -189,9 +419,11 @@ export function NumberInputModal({
className="col-span-3 h-14 rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600" className="col-span-3 h-14 rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600"
onClick={handleConfirm} onClick={handleConfirm}
> >
{step === "package_count" ? "다음" : "확인"}
</button> </button>
</div> </div>
</>
)}
</div> </div>
</DialogPrimitive.Content> </DialogPrimitive.Content>
@ -201,8 +433,18 @@ export function NumberInputModal({
{/* 포장 단위 선택 모달 */} {/* 포장 단위 선택 모달 */}
<PackageUnitModal <PackageUnitModal
open={isPackageModalOpen} open={isPackageModalOpen}
onOpenChange={setIsPackageModalOpen} onOpenChange={(isOpen) => {
onSelect={handlePackageUnitSelect} setIsPackageModalOpen(isOpen);
if (!isOpen && step === "summary") {
// summary에서 추가 포장 모달 닫힘 -> 단위 선택 안 한 경우 유지
}
}}
onSelect={(unitId) => {
handlePackageUnitSelect(unitId);
setIsPackageModalOpen(false);
}}
enabledUnits={packageConfig?.enabledUnits}
customUnits={packageConfig?.customUnits}
/> />
</> </>
); );

View File

@ -2,13 +2,16 @@
import React from "react"; import React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { import {
Dialog, Dialog,
DialogPortal, DialogPortal,
DialogOverlay, DialogOverlay,
DialogClose, DialogClose,
DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import type { CustomPackageUnit } from "../types";
export const PACKAGE_UNITS = [ export const PACKAGE_UNITS = [
{ value: "box", label: "박스", emoji: "📦" }, { value: "box", label: "박스", emoji: "📦" },
@ -24,19 +27,33 @@ export type PackageUnit = (typeof PACKAGE_UNITS)[number]["value"];
interface PackageUnitModalProps { interface PackageUnitModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSelect: (unit: PackageUnit) => void; onSelect: (unit: string) => void;
enabledUnits?: string[];
customUnits?: CustomPackageUnit[];
} }
export function PackageUnitModal({ export function PackageUnitModal({
open, open,
onOpenChange, onOpenChange,
onSelect, onSelect,
enabledUnits,
customUnits,
}: PackageUnitModalProps) { }: PackageUnitModalProps) {
const handleSelect = (unit: PackageUnit) => { const handleSelect = (unitValue: string) => {
onSelect(unit); onSelect(unitValue);
onOpenChange(false); onOpenChange(false);
}; };
// enabledUnits가 undefined면 전체 표시, 배열이면 필터링
const filteredDefaults = enabledUnits
? PACKAGE_UNITS.filter((u) => enabledUnits.includes(u.value))
: [...PACKAGE_UNITS];
const allUnits = [
...filteredDefaults.map((u) => ({ value: u.value, label: u.label, emoji: u.emoji })),
...(customUnits || []).map((cu) => ({ value: cu.id, label: cu.label, emoji: "📦" })),
];
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogPortal> <DialogPortal>
@ -45,18 +62,17 @@ export function PackageUnitModal({
<DialogPrimitive.Content <DialogPrimitive.Content
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-1100 w-full max-w-[90vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-lg border shadow-lg duration-200 sm:max-w-[380px]" className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-1100 w-full max-w-[90vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-lg border shadow-lg duration-200 sm:max-w-[380px]"
> >
{/* 헤더 */} <VisuallyHidden><DialogTitle> </DialogTitle></VisuallyHidden>
<div className="border-b px-4 py-3 pr-12"> <div className="border-b px-4 py-3 pr-12">
<h2 className="text-base font-semibold">📦 </h2> <h2 className="text-base font-semibold"> </h2>
</div> </div>
{/* 3x2 그리드 */}
<div className="grid grid-cols-3 gap-3 p-4"> <div className="grid grid-cols-3 gap-3 p-4">
{PACKAGE_UNITS.map((unit) => ( {allUnits.map((unit) => (
<button <button
key={unit.value} key={unit.value}
type="button" type="button"
onClick={() => handleSelect(unit.value as PackageUnit)} onClick={() => handleSelect(unit.value)}
className="hover:bg-muted active:bg-muted/70 flex flex-col items-center justify-center gap-2 rounded-xl border bg-background px-3 py-5 text-sm font-medium transition-colors" className="hover:bg-muted active:bg-muted/70 flex flex-col items-center justify-center gap-2 rounded-xl border bg-background px-3 py-5 text-sm font-medium transition-colors"
> >
<span className="text-2xl">{unit.emoji}</span> <span className="text-2xl">{unit.emoji}</span>
@ -65,7 +81,12 @@ export function PackageUnitModal({
))} ))}
</div> </div>
{/* X 닫기 버튼 */} {allUnits.length === 0 && (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
</div>
)}
<DialogClose className="ring-offset-background focus:ring-ring absolute top-3 right-3 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none"> <DialogClose className="ring-offset-background focus:ring-ring absolute top-3 right-3 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>

View File

@ -11,8 +11,11 @@
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react"; import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X } from "lucide-react"; import {
import * as LucideIcons from "lucide-react"; Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight,
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { import type {
@ -20,10 +23,11 @@ import type {
CardTemplateConfig, CardTemplateConfig,
CardFieldBinding, CardFieldBinding,
CardInputFieldConfig, CardInputFieldConfig,
CardCalculatedFieldConfig,
CardCartActionConfig, CardCartActionConfig,
CardPackageConfig,
CardPresetSpec, CardPresetSpec,
CartItem, CartItem,
PackageEntry,
} from "../types"; } from "../types";
import { import {
DEFAULT_CARD_IMAGE, DEFAULT_CARD_IMAGE,
@ -31,13 +35,16 @@ import {
} from "../types"; } from "../types";
import { dataApi } from "@/lib/api/data"; import { dataApi } from "@/lib/api/data";
import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useCartSync } from "@/hooks/pop/useCartSync";
import { NumberInputModal } from "./NumberInputModal"; import { NumberInputModal } from "./NumberInputModal";
// Lucide 아이콘 동적 렌더링 const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
};
function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) { function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
if (!name) return <ShoppingCart size={size} />; if (!name) return <ShoppingCart size={size} />;
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>; const IconComp = LUCIDE_ICON_MAP[name];
const IconComp = icons[name];
if (!IconComp) return <ShoppingCart size={size} />; if (!IconComp) return <ShoppingCart size={size} />;
return <IconComp size={size} />; return <IconComp size={size} />;
} }
@ -157,25 +164,92 @@ export function PopCardListComponent({
const dataSource = config?.dataSource; const dataSource = config?.dataSource;
const template = config?.cardTemplate; const template = config?.cardTemplate;
// 이벤트 기반 company_code 필터링 const { subscribe, publish } = usePopEvent(screenId || "default");
const [eventCompanyCode, setEventCompanyCode] = useState<string | undefined>();
const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default");
const router = useRouter(); const router = useRouter();
useEffect(() => { // 장바구니 DB 동기화
if (!screenId) return; const sourceTableName = dataSource?.tableName || "";
const unsub = subscribe("company_selected", (payload: unknown) => { const cartType = config?.cartAction?.cartType;
const p = payload as { companyCode?: string } | undefined; const cart = useCartSync(screenId || "", sourceTableName, cartType);
setEventCompanyCode(p?.companyCode);
});
return unsub;
}, [screenId, subscribe]);
// 데이터 상태 // 데이터 상태
const [rows, setRows] = useState<RowData[]>([]); const [rows, setRows] = useState<RowData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합)
const [externalFilters, setExternalFilters] = useState<
Map<string, {
fieldName: string;
value: unknown;
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
}>
>(new Map());
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__filter_condition`,
(payload: unknown) => {
const data = payload as {
value?: { fieldName?: string; value?: unknown };
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
_connectionId?: string;
};
const connId = data?._connectionId || "default";
setExternalFilters(prev => {
const next = new Map(prev);
if (data?.value?.value) {
next.set(connId, {
fieldName: data.value.fieldName || "",
value: data.value.value,
filterConfig: data.filterConfig,
});
} else {
next.delete(connId);
}
return next;
});
}
);
return unsub;
}, [componentId, subscribe]);
// cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용
const cartRef = useRef(cart);
cartRef.current = cart;
// "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__cart_save_trigger`,
async (payload: unknown) => {
const data = payload as { value?: { selectedColumns?: string[] } } | undefined;
const ok = await cartRef.current.saveToDb(data?.value?.selectedColumns);
publish(`__comp_output__${componentId}__cart_save_completed`, {
success: ok,
});
}
);
return unsub;
}, [componentId, subscribe, publish]);
// DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달
useEffect(() => {
if (!componentId || cart.loading) return;
publish(`__comp_output__${componentId}__cart_updated`, {
count: cart.cartCount,
isDirty: cart.isDirty,
});
}, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish]);
// 카드 선택 시 selected_row 이벤트 발행
const handleCardSelect = useCallback((row: RowData) => {
if (!componentId) return;
publish(`__comp_output__${componentId}__selected_row`, row);
}, [componentId, publish]);
// 확장/페이지네이션 상태 // 확장/페이지네이션 상태
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@ -190,7 +264,9 @@ export function PopCardListComponent({
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => { const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect; const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
if (width > 0) setContainerWidth(width); if (width > 0) setContainerWidth(width);
if (height > 0) setContainerHeight(height); if (height > 0) setContainerHeight(height);
}); });
@ -200,9 +276,10 @@ export function PopCardListComponent({
// 이미지 URL 없는 항목 카운트 (toast 중복 방지용) // 이미지 URL 없는 항목 카운트 (toast 중복 방지용)
const missingImageCountRef = useRef(0); const missingImageCountRef = useRef(0);
const toastShownRef = useRef(false);
const spec: CardPresetSpec = CARD_PRESET_SPECS.large;
const cardSizeKey = config?.cardSize || "large";
const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large;
// 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열 // 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열
const maxAllowedColumns = useMemo(() => { const maxAllowedColumns = useMemo(() => {
@ -216,67 +293,82 @@ export function PopCardListComponent({
const autoColumns = containerWidth > 0 const autoColumns = containerWidth > 0
? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap))) ? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap)))
: maxGridColumns; : maxGridColumns;
const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns); const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns));
// 컨테이너 높이 기반 행 수 자동 계산 (잘림 방지) // 행 수: 설정값 그대로 사용 (containerHeight 연동 시 피드백 루프 위험)
const effectiveGridRows = useMemo(() => { const gridRows = configGridRows;
if (containerHeight <= 0) return configGridRows;
const controlBarHeight = 44; // 카드 크기: 높이는 프리셋 고정, 너비만 컨테이너 기반 동적 계산
const effectiveHeight = baseContainerHeight.current > 0 // (높이를 containerHeight에 연동하면 뷰어 모드의 minmax(auto) 그리드와
? baseContainerHeight.current // ResizeObserver 사이에서 피드백 루프가 발생해 무한 성장함)
: containerHeight;
const availableHeight = effectiveHeight - controlBarHeight;
const cardHeightWithGap = spec.height + spec.gap;
const fittableRows = Math.max(1, Math.floor(
(availableHeight + spec.gap) / cardHeightWithGap
));
return Math.min(configGridRows, fittableRows);
}, [containerHeight, configGridRows, spec]);
const gridRows = effectiveGridRows;
// 카드 크기: 컨테이너 실측 크기에서 gridColumns x gridRows 기준으로 동적 계산
const scaled = useMemo((): ScaledConfig => { const scaled = useMemo((): ScaledConfig => {
const gap = spec.gap; const gap = spec.gap;
const controlBarHeight = 44;
const buildScaledConfig = (cardWidth: number, cardHeight: number): ScaledConfig => { const cardHeight = spec.height;
const scale = cardHeight / spec.height; const minCardWidth = Math.round(spec.height * 1.6);
const cardWidth = containerWidth > 0
? Math.max(minCardWidth,
Math.floor((containerWidth - gap * (gridColumns - 1)) / gridColumns))
: minCardWidth;
return { return {
cardHeight, cardHeight,
cardWidth, cardWidth,
imageSize: Math.round(spec.imageSize * scale), imageSize: spec.imageSize,
padding: Math.round(spec.padding * scale), padding: spec.padding,
gap, gap,
headerPaddingX: Math.round(spec.headerPadX * scale), headerPaddingX: spec.headerPadX,
headerPaddingY: Math.round(spec.headerPadY * scale), headerPaddingY: spec.headerPadY,
codeTextSize: Math.round(spec.codeText * scale), codeTextSize: spec.codeText,
titleTextSize: Math.round(spec.titleText * scale), titleTextSize: spec.titleText,
bodyTextSize: Math.round(spec.bodyText * scale), bodyTextSize: spec.bodyText,
};
}; };
}, [spec, containerWidth, gridColumns]);
if (containerWidth <= 0 || containerHeight <= 0) { // 외부 필터 적용 (복수 필터 AND 결합)
return buildScaledConfig(Math.round(spec.height * 1.6), spec.height); const filteredRows = useMemo(() => {
if (externalFilters.size === 0) return rows;
const matchSingleFilter = (
row: RowData,
filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } }
): boolean => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
const fc = filter.filterConfig;
const columns: string[] =
fc?.targetColumns?.length
? fc.targetColumns
: fc?.targetColumn
? [fc.targetColumn]
: filter.fieldName
? [filter.fieldName]
: [];
if (columns.length === 0) return true;
const mode = fc?.filterMode || "contains";
const matchCell = (cellValue: string) => {
switch (mode) {
case "equals":
return cellValue === searchValue;
case "starts_with":
return cellValue.startsWith(searchValue);
case "contains":
default:
return cellValue.includes(searchValue);
} }
};
const effectiveHeight = baseContainerHeight.current > 0 return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase()));
? baseContainerHeight.current };
: containerHeight;
const availableHeight = effectiveHeight - controlBarHeight; const allFilters = [...externalFilters.values()];
const availableWidth = containerWidth; return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f)));
}, [rows, externalFilters]);
const cardHeight = Math.max(spec.height,
Math.floor((availableHeight - gap * (gridRows - 1)) / gridRows));
const cardWidth = Math.max(Math.round(spec.height * 1.6),
Math.floor((availableWidth - gap * (gridColumns - 1)) / gridColumns));
return buildScaledConfig(cardWidth, cardHeight);
}, [spec, containerWidth, containerHeight, gridColumns, gridRows]);
// 기본 상태에서 표시할 카드 수 // 기본 상태에서 표시할 카드 수
const visibleCardCount = useMemo(() => { const visibleCardCount = useMemo(() => {
@ -284,7 +376,7 @@ export function PopCardListComponent({
}, [gridColumns, gridRows]); }, [gridColumns, gridRows]);
// 더보기 버튼 표시 여부 // 더보기 버튼 표시 여부
const hasMoreCards = rows.length > visibleCardCount; const hasMoreCards = filteredRows.length > visibleCardCount;
// 확장 상태에서 표시할 카드 수 계산 // 확장 상태에서 표시할 카드 수 계산
const expandedCardsPerPage = useMemo(() => { const expandedCardsPerPage = useMemo(() => {
@ -300,19 +392,17 @@ export function PopCardListComponent({
// 현재 표시할 카드 결정 // 현재 표시할 카드 결정
const displayCards = useMemo(() => { const displayCards = useMemo(() => {
if (!isExpanded) { if (!isExpanded) {
// 기본 상태: visibleCardCount만큼만 표시 return filteredRows.slice(0, visibleCardCount);
return rows.slice(0, visibleCardCount);
} else { } else {
// 확장 상태: 현재 페이지의 카드들 (스크롤 가능하도록 더 많이)
const start = (currentPage - 1) * expandedCardsPerPage; const start = (currentPage - 1) * expandedCardsPerPage;
const end = start + expandedCardsPerPage; const end = start + expandedCardsPerPage;
return rows.slice(start, end); return filteredRows.slice(start, end);
} }
}, [rows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]);
// 총 페이지 수 // 총 페이지 수
const totalPages = isExpanded const totalPages = isExpanded
? Math.ceil(rows.length / expandedCardsPerPage) ? Math.ceil(filteredRows.length / expandedCardsPerPage)
: 1; : 1;
// 페이지네이션 필요: 확장 상태에서 전체 카드가 한 페이지를 초과할 때 // 페이지네이션 필요: 확장 상태에서 전체 카드가 한 페이지를 초과할 때
const needsPagination = isExpanded && totalPages > 1; const needsPagination = isExpanded && totalPages > 1;
@ -358,7 +448,12 @@ export function PopCardListComponent({
} }
}, [currentPage, isExpanded]); }, [currentPage, isExpanded]);
// 데이터 조회 // dataSource를 직렬화해서 의존성 안정화 (객체 참조 변경에 의한 불필요한 재호출 방지)
const dataSourceKey = useMemo(
() => JSON.stringify(dataSource || null),
[dataSource]
);
useEffect(() => { useEffect(() => {
if (!dataSource?.tableName) { if (!dataSource?.tableName) {
setLoading(false); setLoading(false);
@ -370,10 +465,8 @@ export function PopCardListComponent({
setLoading(true); setLoading(true);
setError(null); setError(null);
missingImageCountRef.current = 0; missingImageCountRef.current = 0;
toastShownRef.current = false;
try { try {
// 필터 조건 구성
const filters: Record<string, unknown> = {}; const filters: Record<string, unknown> = {};
if (dataSource.filters && dataSource.filters.length > 0) { if (dataSource.filters && dataSource.filters.length > 0) {
dataSource.filters.forEach((f) => { dataSource.filters.forEach((f) => {
@ -383,28 +476,25 @@ export function PopCardListComponent({
}); });
} }
// 이벤트로 수신한 company_code 필터 병합 // 다중 정렬: 첫 번째 기준을 서버 정렬로 전달 (하위 호환: 단일 객체도 처리)
if (eventCompanyCode) { const sortArray = Array.isArray(dataSource.sort)
filters["company_code"] = eventCompanyCode; ? dataSource.sort
} : dataSource.sort && typeof dataSource.sort === "object"
? [dataSource.sort as { column: string; direction: "asc" | "desc" }]
: [];
const primarySort = sortArray.length > 0 ? sortArray[0] : undefined;
const sortBy = primarySort?.column;
const sortOrder = primarySort?.direction;
// 정렬 조건
const sortBy = dataSource.sort?.column;
const sortOrder = dataSource.sort?.direction;
// 개수 제한
const size = const size =
dataSource.limit?.mode === "limited" && dataSource.limit?.count dataSource.limit?.mode === "limited" && dataSource.limit?.count
? dataSource.limit.count ? dataSource.limit.count
: 100; : 100;
// TODO: 조인 지원은 추후 구현
// 현재는 단일 테이블 조회만 지원
const result = await dataApi.getTableData(dataSource.tableName, { const result = await dataApi.getTableData(dataSource.tableName, {
page: 1, page: 1,
size, size,
sortBy: sortOrder ? sortBy : undefined, sortBy: sortBy || undefined,
sortOrder, sortOrder,
filters: Object.keys(filters).length > 0 ? filters : undefined, filters: Object.keys(filters).length > 0 ? filters : undefined,
}); });
@ -420,27 +510,13 @@ export function PopCardListComponent({
}; };
fetchData(); fetchData();
}, [dataSource, eventCompanyCode]); }, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps
// 이미지 URL 없는 항목 체크 및 toast 표시 // 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시)
useEffect(() => { useEffect(() => {
if ( if (!loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn) {
!loading &&
rows.length > 0 &&
template?.image?.enabled &&
template?.image?.imageColumn &&
!toastShownRef.current
) {
const imageColumn = template.image.imageColumn; const imageColumn = template.image.imageColumn;
const missingCount = rows.filter((row) => !row[imageColumn]).length; missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length;
if (missingCount > 0) {
missingImageCountRef.current = missingCount;
toastShownRef.current = true;
toast.warning(
`${missingCount}개 항목의 이미지 URL이 없어 기본 이미지로 표시됩니다`
);
}
} }
}, [loading, rows, template?.image]); }, [loading, rows, template?.image]);
@ -503,21 +579,29 @@ export function PopCardListComponent({
justifyContent: isHorizontalMode ? "start" : "center", justifyContent: isHorizontalMode ? "start" : "center",
}} }}
> >
{displayCards.map((row, index) => ( {displayCards.map((row, index) => {
const codeValue = template?.header?.codeField && row[template.header.codeField]
? String(row[template.header.codeField])
: null;
const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`;
return (
<Card <Card
key={index} key={rowKey}
row={row} row={row}
template={template} template={template}
scaled={scaled} scaled={scaled}
inputField={config?.inputField} inputField={config?.inputField}
calculatedField={config?.calculatedField} packageConfig={config?.packageConfig}
cartAction={config?.cartAction} cartAction={config?.cartAction}
publish={publish} publish={publish}
getSharedData={getSharedData}
setSharedData={setSharedData}
router={router} router={router}
onSelect={handleCardSelect}
cart={cart}
codeFieldName={template?.header?.codeField}
parentComponentId={componentId}
/> />
))} );
})}
</div> </div>
{/* 하단 컨트롤 영역 */} {/* 하단 컨트롤 영역 */}
@ -544,7 +628,7 @@ export function PopCardListComponent({
)} )}
</Button> </Button>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{rows.length} {filteredRows.length}{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}` : ""}
</span> </span>
</div> </div>
@ -589,69 +673,75 @@ function Card({
template, template,
scaled, scaled,
inputField, inputField,
calculatedField, packageConfig,
cartAction, cartAction,
publish, publish,
getSharedData,
setSharedData,
router, router,
onSelect,
cart,
codeFieldName,
parentComponentId,
}: { }: {
row: RowData; row: RowData;
template?: CardTemplateConfig; template?: CardTemplateConfig;
scaled: ScaledConfig; scaled: ScaledConfig;
inputField?: CardInputFieldConfig; inputField?: CardInputFieldConfig;
calculatedField?: CardCalculatedFieldConfig; packageConfig?: CardPackageConfig;
cartAction?: CardCartActionConfig; cartAction?: CardCartActionConfig;
publish: (eventName: string, payload?: unknown) => void; publish: (eventName: string, payload?: unknown) => void;
getSharedData: <T = unknown>(key: string) => T | undefined;
setSharedData: (key: string, value: unknown) => void;
router: ReturnType<typeof useRouter>; router: ReturnType<typeof useRouter>;
onSelect?: (row: RowData) => void;
cart: ReturnType<typeof useCartSync>;
codeFieldName?: string;
parentComponentId?: string;
}) { }) {
const header = template?.header; const header = template?.header;
const image = template?.image; const image = template?.image;
const body = template?.body; const body = template?.body;
// 입력 필드 상태 const [inputValue, setInputValue] = useState<number>(0);
const [inputValue, setInputValue] = useState<number>(
inputField?.defaultValue || 0
);
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined); const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
// 담기/취소 토글 상태
const [isCarted, setIsCarted] = useState(false);
// 헤더 값 추출
const codeValue = header?.codeField ? row[header.codeField] : null; const codeValue = header?.codeField ? row[header.codeField] : null;
const titleValue = header?.titleField ? row[header.titleField] : null; const titleValue = header?.titleField ? row[header.titleField] : null;
// 이미지 URL 결정 // 장바구니 상태: codeField 값을 rowKey로 사용
const rowKey = codeFieldName && row[codeFieldName] ? String(row[codeFieldName]) : "";
const isCarted = cart.isItemInCart(rowKey);
const existingCartItem = cart.getCartItem(rowKey);
// DB에서 로드된 장바구니 품목이면 입력값 복원
useEffect(() => {
if (existingCartItem && existingCartItem._origin === "db") {
setInputValue(existingCartItem.quantity);
setPackageUnit(existingCartItem.packageUnit);
setPackageEntries(existingCartItem.packageEntries || []);
}
}, [existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]);
const imageUrl = const imageUrl =
image?.enabled && image?.imageColumn && row[image.imageColumn] image?.enabled && image?.imageColumn && row[image.imageColumn]
? String(row[image.imageColumn]) ? String(row[image.imageColumn])
: image?.defaultImage || DEFAULT_CARD_IMAGE; : image?.defaultImage || DEFAULT_CARD_IMAGE;
// 계산 필드 값 계산 // limitColumn 우선, 하위 호환으로 maxColumn 폴백
const calculatedValue = useMemo(() => { const limitCol = inputField?.limitColumn || inputField?.maxColumn;
if (!calculatedField?.enabled || !calculatedField?.formula) return null;
return evaluateFormula(calculatedField.formula, row, inputValue);
}, [calculatedField, row, inputValue]);
// effectiveMax: DB 컬럼 우선, 없으면 inputField.max 폴백
const effectiveMax = useMemo(() => { const effectiveMax = useMemo(() => {
if (inputField?.maxColumn) { if (limitCol) {
const colVal = Number(row[inputField.maxColumn]); const colVal = Number(row[limitCol]);
if (!isNaN(colVal) && colVal > 0) return colVal; if (!isNaN(colVal) && colVal > 0) return colVal;
} }
return inputField?.max ?? 999999; return 999999;
}, [inputField, row]); }, [limitCol, row]);
// 기본값이 설정되지 않은 경우 최대값으로 자동 초기화 // 제한 컬럼이 있으면 최대값으로 자동 초기화
useEffect(() => { useEffect(() => {
if (inputField?.enabled && !inputField?.defaultValue && effectiveMax > 0 && effectiveMax < 999999) { if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
setInputValue(effectiveMax); setInputValue(effectiveMax);
} }
}, [effectiveMax, inputField?.enabled, inputField?.defaultValue]); }, [effectiveMax, inputField?.enabled, limitCol]);
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
height: `${scaled.cardHeight}px`, height: `${scaled.cardHeight}px`,
@ -677,43 +767,43 @@ function Card({
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleInputConfirm = (value: number, unit?: string) => { const handleInputConfirm = (value: number, unit?: string, entries?: PackageEntry[]) => {
setInputValue(value); setInputValue(value);
setPackageUnit(unit); setPackageUnit(unit);
setPackageEntries(entries || []);
}; };
// 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글 // 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달
const handleCartAdd = () => { const handleCartAdd = () => {
if (!rowKey) return;
const cartItem: CartItem = { const cartItem: CartItem = {
row, row,
quantity: inputValue, quantity: inputValue,
packageUnit: packageUnit || undefined, packageUnit: packageUnit || undefined,
packageEntries: packageEntries.length > 0 ? packageEntries : undefined,
}; };
const existing = getSharedData<CartItem[]>("cart_items") || []; cart.addItem(cartItem, rowKey);
setSharedData("cart_items", [...existing, cartItem]); if (parentComponentId) {
publish("cart_item_added", cartItem); publish(`__comp_output__${parentComponentId}__cart_updated`, {
count: cart.cartCount + 1,
setIsCarted(true); isDirty: true,
toast.success("장바구니에 담겼습니다."); });
if (cartAction?.navigateMode === "screen" && cartAction.targetScreenId) {
router.push(`/pop/screens/${cartAction.targetScreenId}`);
} }
}; };
// 취소: sharedData에서 해당 아이템 제거 + 이벤트 발행 + 토글 복원 // 취소: 로컬 상태에서만 제거 + 연결 시스템으로 카운트 전달
const handleCartCancel = () => { const handleCartCancel = () => {
const existing = getSharedData<CartItem[]>("cart_items") || []; if (!rowKey) return;
const rowKey = JSON.stringify(row);
const filtered = existing.filter(
(item) => JSON.stringify(item.row) !== rowKey
);
setSharedData("cart_items", filtered);
publish("cart_item_removed", { row });
setIsCarted(false); cart.removeItem(rowKey);
toast.info("장바구니에서 제거되었습니다."); if (parentComponentId) {
publish(`__comp_output__${parentComponentId}__cart_updated`, {
count: Math.max(0, cart.cartCount - 1),
isDirty: true,
});
}
}; };
// pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영) // pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영)
@ -721,14 +811,26 @@ function Card({
const cartLabel = cartAction?.label || "담기"; const cartLabel = cartAction?.label || "담기";
const cancelLabel = cartAction?.cancelLabel || "취소"; const cancelLabel = cartAction?.cancelLabel || "취소";
const handleCardClick = () => {
onSelect?.(row);
};
return ( return (
<div <div
className="rounded-lg border bg-card shadow-sm transition-all duration-150 hover:border-2 hover:border-blue-500 hover:shadow-md" className={`cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${
isCarted
? "border-emerald-500 border-2 hover:border-emerald-600"
: "hover:border-2 hover:border-blue-500"
}`}
style={cardStyle} style={cardStyle}
onClick={handleCardClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }}
> >
{/* 헤더 영역 */} {/* 헤더 영역 */}
{(codeValue !== null || titleValue !== null) && ( {(codeValue !== null || titleValue !== null) && (
<div className="border-b bg-muted/30" style={headerStyle}> <div className={`border-b ${isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30"}`} style={headerStyle}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{codeValue !== null && ( {codeValue !== null && (
<span <span
@ -777,7 +879,7 @@ function Card({
<div style={{ display: "flex", flexDirection: "column", gap: `${Math.round(scaled.gap / 2)}px` }}> <div style={{ display: "flex", flexDirection: "column", gap: `${Math.round(scaled.gap / 2)}px` }}>
{body?.fields && body.fields.length > 0 ? ( {body?.fields && body.fields.length > 0 ? (
body.fields.map((field) => ( body.fields.map((field) => (
<FieldRow key={field.id} field={field} row={row} scaled={scaled} /> <FieldRow key={field.id} field={field} row={row} scaled={scaled} inputValue={inputValue} />
)) ))
) : ( ) : (
<div <div
@ -787,34 +889,17 @@ function Card({
</div> </div>
)} )}
{/* 계산 필드 */}
{calculatedField?.enabled && calculatedValue !== null && (
<div
className="flex items-baseline"
style={{ gap: `${Math.round(scaled.gap / 2)}px`, fontSize: `${scaled.bodyTextSize}px` }}
>
<span
className="shrink-0 text-muted-foreground"
style={{ minWidth: `${Math.round(50 * (scaled.bodyTextSize / 12))}px` }}
>
{calculatedField.label || "계산값"}
</span>
<MarqueeText className="font-medium text-orange-600">
{calculatedValue.toLocaleString()}{calculatedField.unit ? ` ${calculatedField.unit}` : ""}
</MarqueeText>
</div>
)}
</div> </div>
</div> </div>
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (inputField 활성화 시만) */} {/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */}
{inputField?.enabled && ( {(inputField?.enabled || cartAction) && (
<div <div
className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2" className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2"
style={{ minWidth: "100px" }} style={{ minWidth: "100px" }}
> >
{/* 수량 버튼 */} {/* 수량 버튼 (입력 필드 ON일 때만) */}
{inputField?.enabled && (
<button <button
type="button" type="button"
onClick={handleInputClick} onClick={handleInputClick}
@ -827,8 +912,11 @@ function Card({
{inputField.unit || "EA"} {inputField.unit || "EA"}
</span> </span>
</button> </button>
)}
{/* pop-icon 스타일 담기/취소 토글 버튼 */} {/* 담기/취소 버튼 (cartAction 존재 시 항상 표시) */}
{cartAction && (
<>
{isCarted ? ( {isCarted ? (
<button <button
type="button" type="button"
@ -844,7 +932,7 @@ function Card({
<button <button
type="button" type="button"
onClick={handleCartAdd} onClick={handleCartAdd}
className="flex flex-col items-center justify-center gap-0.5 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700" className="flex flex-col items-center justify-center gap-0.5 rounded-xl bg-linear-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700"
> >
{cartAction?.iconType === "emoji" && cartAction?.iconValue ? ( {cartAction?.iconType === "emoji" && cartAction?.iconValue ? (
<span style={{ fontSize: `${iconSize}px` }}>{cartAction.iconValue}</span> <span style={{ fontSize: `${iconSize}px` }}>{cartAction.iconValue}</span>
@ -856,11 +944,12 @@ function Card({
</span> </span>
</button> </button>
)} )}
</>
)}
</div> </div>
)} )}
</div> </div>
{/* 숫자 입력 모달 */}
{inputField?.enabled && ( {inputField?.enabled && (
<NumberInputModal <NumberInputModal
open={isModalOpen} open={isModalOpen}
@ -868,8 +957,9 @@ function Card({
unit={inputField.unit || "EA"} unit={inputField.unit || "EA"}
initialValue={inputValue} initialValue={inputValue}
initialPackageUnit={packageUnit} initialPackageUnit={packageUnit}
min={inputField.min || 0}
maxValue={effectiveMax} maxValue={effectiveMax}
packageConfig={packageConfig}
showPackageUnit={inputField.showPackageUnit}
onConfirm={handleInputConfirm} onConfirm={handleInputConfirm}
/> />
)} )}
@ -883,14 +973,54 @@ function FieldRow({
field, field,
row, row,
scaled, scaled,
inputValue,
}: { }: {
field: CardFieldBinding; field: CardFieldBinding;
row: RowData; row: RowData;
scaled: ScaledConfig; scaled: ScaledConfig;
inputValue?: number;
}) { }) {
const value = row[field.columnName]; const valueType = field.valueType || "column";
// 비율 기반 라벨 최소 너비 const displayValue = useMemo(() => {
if (valueType !== "formula") {
return formatValue(field.columnName ? row[field.columnName] : undefined);
}
// 구조화된 수식 우선
if (field.formulaLeft && field.formulaOperator) {
const rightVal = field.formulaRightType === "input"
? (inputValue ?? 0)
: Number(row[field.formulaRight || ""] ?? 0);
const leftVal = Number(row[field.formulaLeft] ?? 0);
let result: number | null = null;
switch (field.formulaOperator) {
case "+": result = leftVal + rightVal; break;
case "-": result = leftVal - rightVal; break;
case "*": result = leftVal * rightVal; break;
case "/": result = rightVal !== 0 ? leftVal / rightVal : null; break;
}
if (result !== null && isFinite(result)) {
const formatted = Math.round(result * 100) / 100;
return field.unit ? `${formatted.toLocaleString()} ${field.unit}` : formatted.toLocaleString();
}
return "-";
}
// 하위 호환: 레거시 formula 문자열
if (field.formula) {
const result = evaluateFormula(field.formula, row, inputValue ?? 0);
if (result !== null) {
const formatted = result.toLocaleString();
return field.unit ? `${formatted} ${field.unit}` : formatted;
}
}
return "-";
}, [valueType, field, row, inputValue]);
const isFormula = valueType === "formula";
const labelMinWidth = Math.round(50 * (scaled.bodyTextSize / 12)); const labelMinWidth = Math.round(50 * (scaled.bodyTextSize / 12));
return ( return (
@ -898,19 +1028,17 @@ function FieldRow({
className="flex items-baseline" className="flex items-baseline"
style={{ gap: `${Math.round(scaled.gap / 2)}px`, fontSize: `${scaled.bodyTextSize}px` }} style={{ gap: `${Math.round(scaled.gap / 2)}px`, fontSize: `${scaled.bodyTextSize}px` }}
> >
{/* 라벨 */}
<span <span
className="shrink-0 text-muted-foreground" className="shrink-0 text-muted-foreground"
style={{ minWidth: `${labelMinWidth}px` }} style={{ minWidth: `${labelMinWidth}px` }}
> >
{field.label} {field.label}
</span> </span>
{/* 값 - 기본 검정색, textColor 설정 시 해당 색상 */}
<MarqueeText <MarqueeText
className="font-medium" className="font-medium"
style={{ color: field.textColor || "#000000" }} style={{ color: field.textColor || (isFormula ? "#ea580c" : "#000000") }}
> >
{formatValue(value)} {displayValue}
</MarqueeText> </MarqueeText>
</div> </div>
); );
@ -982,7 +1110,6 @@ function evaluateFormula(
// 안전한 계산 (기본 산술 연산만 허용) // 안전한 계산 (기본 산술 연산만 허용)
// 허용: 숫자, +, -, *, /, (, ), 공백, 소수점 // 허용: 숫자, +, -, *, /, (, ), 공백, 소수점
if (!/^[\d\s+\-*/().]+$/.test(expression)) { if (!/^[\d\s+\-*/().]+$/.test(expression)) {
console.warn("Invalid formula expression:", expression);
return null; return null;
} }
@ -995,7 +1122,7 @@ function evaluateFormula(
return Math.round(result * 100) / 100; // 소수점 2자리까지 return Math.round(result * 100) / 100; // 소수점 2자리까지
} catch (error) { } catch (error) {
console.warn("Formula evaluation error:", error); // 수식 평가 실패 시 null 반환
return null; return null;
} }
} }

View File

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

View File

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

View File

@ -364,11 +364,24 @@ export interface CardColumnFilter {
// ----- 본문 필드 바인딩 (라벨-값 쌍) ----- // ----- 본문 필드 바인딩 (라벨-값 쌍) -----
export type FieldValueType = "column" | "formula";
export type FormulaOperator = "+" | "-" | "*" | "/";
export type FormulaRightType = "column" | "input";
export interface CardFieldBinding { export interface CardFieldBinding {
id: string; id: string;
columnName: string; // DB 컬럼명 label: string;
label: string; // 표시 라벨 (예: "발주일") valueType: FieldValueType;
textColor?: string; // 텍스트 색상 (예: "#ef4444" 빨간색) columnName?: string; // valueType === "column"일 때 DB 컬럼명
// 구조화된 수식 (클릭형 빌더)
formulaLeft?: string; // 왼쪽: DB 컬럼명
formulaOperator?: FormulaOperator;
formulaRightType?: FormulaRightType; // "column" 또는 "input"($input)
formulaRight?: string; // rightType === "column"일 때 DB 컬럼명
/** @deprecated 구조화 수식 필드 사용, 하위 호환용 */
formula?: string;
unit?: string;
textColor?: string;
} }
// ----- 카드 헤더 설정 (코드 + 제목) ----- // ----- 카드 헤더 설정 (코드 + 제목) -----
@ -406,11 +419,16 @@ export interface CardTemplateConfig {
// ----- 데이터 소스 (테이블 단위) ----- // ----- 데이터 소스 (테이블 단위) -----
export interface CardSortConfig {
column: string;
direction: "asc" | "desc";
}
export interface CardListDataSource { export interface CardListDataSource {
tableName: string; tableName: string;
joins?: CardColumnJoin[]; joins?: CardColumnJoin[];
filters?: CardColumnFilter[]; filters?: CardColumnFilter[];
sort?: { column: string; direction: "asc" | "desc" }; sort?: CardSortConfig[];
limit?: { mode: "all" | "limited"; count?: number }; limit?: { mode: "all" | "limited"; count?: number };
} }
@ -437,44 +455,84 @@ export const CARD_SCROLL_DIRECTION_LABELS: Record<CardScrollDirection, string> =
export interface CardInputFieldConfig { export interface CardInputFieldConfig {
enabled: boolean; enabled: boolean;
columnName?: string; // 입력값이 저장될 컬럼
label?: string; // 표시 라벨 (예: "발주 수량")
unit?: string; // 단위 (예: "EA", "개")
defaultValue?: number; // 기본값
min?: number; // 최소값
max?: number; // 최대값
maxColumn?: string; // 최대값을 DB 컬럼에서 동적으로 가져올 컬럼명 (설정 시 row[maxColumn] 우선)
step?: number; // 증감 단위
}
// ----- 카드 내 계산 필드 설정 -----
export interface CardCalculatedFieldConfig {
enabled: boolean;
label?: string; // 표시 라벨 (예: "미입고")
formula: string; // 계산식 (예: "order_qty - inbound_qty")
sourceColumns: string[]; // 계산에 사용되는 컬럼들
resultColumn?: string; // 결과를 저장할 컬럼 (선택)
unit?: string; // 단위 (예: "EA") unit?: string; // 단위 (예: "EA")
limitColumn?: string; // 제한 기준 컬럼 (해당 행의 이 컬럼 값이 최대값)
saveTable?: string; // 저장 대상 테이블
saveColumn?: string; // 저장 대상 컬럼
/** @deprecated limitColumn 사용 */
maxColumn?: string;
/** @deprecated 미사용, 하위 호환용 */
label?: string;
/** @deprecated packageConfig로 이동, 하위 호환용 */
showPackageUnit?: boolean;
} }
// ----- 담기 버튼 데이터 구조 (추후 API 연동 시 이 shape 그대로 사용) ----- // ----- 포장등록 설정 -----
export interface CustomPackageUnit {
id: string;
label: string; // 표시명 (예: "파렛트")
}
export interface CardPackageConfig {
enabled: boolean; // 포장등록 기능 ON/OFF
enabledUnits?: string[]; // 활성화된 기본 단위 (예: ["box", "bag"]), undefined면 전체 표시
customUnits?: CustomPackageUnit[]; // 디자이너가 추가한 커스텀 단위
showSummaryMessage?: boolean; // 계산 결과 안내 메시지 표시 (기본 true)
}
// ----- 포장 내역 (2단계 계산 결과) -----
export interface PackageEntry {
unitId: string; // 포장 단위 ID (예: "box")
unitLabel: string; // 포장 단위 표시명 (예: "박스")
packageCount: number; // 포장 수량 (예: 3)
quantityPerUnit: number; // 개당 수량 (예: 80)
totalQuantity: number; // 합계 = packageCount * quantityPerUnit
}
// ----- 담기 버튼 데이터 구조 (로컬 상태용) -----
export interface CartItem { export interface CartItem {
row: Record<string, unknown>; // 카드 원본 행 데이터 row: Record<string, unknown>; // 카드 원본 행 데이터
quantity: number; // 입력 수량 quantity: number; // 입력 수량
packageUnit?: string; // 포장 단위 (box/bag/pack/bundle/roll/barrel) packageUnit?: string; // 포장 단위 (box/bag/pack/bundle/roll/barrel)
packageEntries?: PackageEntry[]; // 포장 내역 (2단계 계산 시)
} }
// ----- 장바구니 DB 연동용 확장 타입 -----
export type CartSyncStatus = "clean" | "dirty" | "saving";
export type CartItemOrigin = "db" | "local";
export type CartItemStatus = "in_cart" | "confirmed" | "cancelled";
export interface CartItemWithId extends CartItem {
cartId?: string; // DB id (UUID, 저장 후 할당)
sourceTable: string; // 원본 테이블명
rowKey: string; // 원본 행 식별키 (codeField 값)
status: CartItemStatus;
_origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가
memo?: string;
}
// ----- 담기 버튼 액션 설정 (pop-icon 스타일 + 장바구니 연동) ----- // ----- 담기 버튼 액션 설정 (pop-icon 스타일 + 장바구니 연동) -----
export type CartSaveMode = "cart" | "direct";
export interface CardCartActionConfig { export interface CardCartActionConfig {
navigateMode: "none" | "screen"; // 담기 후 이동 모드 saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장
targetScreenId?: string; // 장바구니 POP 화면 ID (screen 모드) cartType?: string; // 장바구니 구분값 (예: "purchase_inbound")
iconType?: "lucide" | "emoji"; // 아이콘 타입
iconValue?: string; // Lucide 아이콘명 또는 이모지 값
label?: string; // 담기 라벨 (기본: "담기") label?: string; // 담기 라벨 (기본: "담기")
cancelLabel?: string; // 취소 라벨 (기본: "취소") cancelLabel?: string; // 취소 라벨 (기본: "취소")
// 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호)
dataFields?: { sourceField: string; targetField?: string; label?: string }[];
// 하위 호환: 기존 필드 (사용하지 않지만 기존 데이터 보호)
navigateMode?: "none" | "screen";
targetScreenId?: string;
iconType?: "lucide" | "emoji";
iconValue?: string;
} }
// ----- pop-card-list 전체 설정 ----- // ----- pop-card-list 전체 설정 -----
@ -533,31 +591,39 @@ export const CARD_PRESET_SPECS: Record<CardSize, CardPresetSpec> = {
}, },
}; };
// ----- 반응형 표시 모드 -----
export type ResponsiveDisplayMode = "required" | "shrink" | "hidden";
export const RESPONSIVE_DISPLAY_LABELS: Record<ResponsiveDisplayMode, string> = {
required: "필수",
shrink: "축소",
hidden: "숨김",
};
export interface CardResponsiveConfig {
code?: ResponsiveDisplayMode;
title?: ResponsiveDisplayMode;
image?: ResponsiveDisplayMode;
fields?: Record<string, ResponsiveDisplayMode>;
}
// ----- pop-card-list 전체 설정 -----
export interface PopCardListConfig { export interface PopCardListConfig {
// 데이터 소스 (테이블 단위)
dataSource: CardListDataSource; dataSource: CardListDataSource;
// 카드 템플릿 (헤더 + 이미지 + 본문)
cardTemplate: CardTemplateConfig; cardTemplate: CardTemplateConfig;
// 스크롤 방향
scrollDirection: CardScrollDirection; scrollDirection: CardScrollDirection;
cardsPerRow?: number; // deprecated, gridColumns 사용 cardsPerRow?: number; // deprecated, gridColumns 사용
cardSize: CardSize; // 프리셋 크기 (small/medium/large) cardSize: CardSize;
// 그리드 배치 설정 (가로 x 세로) gridColumns?: number;
gridColumns?: number; // 가로 카드 수 (기본값: 3) gridRows?: number;
gridRows?: number; // 세로 카드 수 (기본값: 2)
// 확장 설정 (더보기 클릭 시 그리드 rowSpan 변경) // 반응형 표시 설정
// expandedRowSpan 제거됨 - 항상 원래 크기의 2배로 확장 responsiveDisplay?: CardResponsiveConfig;
// 입력 필드 설정 (수량 입력 등)
inputField?: CardInputFieldConfig; inputField?: CardInputFieldConfig;
packageConfig?: CardPackageConfig;
// 계산 필드 설정 (미입고 등 자동 계산)
calculatedField?: CardCalculatedFieldConfig;
// 담기 버튼 액션 설정 (pop-icon 스타일)
cartAction?: CardCartActionConfig; cartAction?: CardCartActionConfig;
} }

View File

@ -558,31 +558,7 @@ export class ButtonActionExecutor {
return false; return false;
} }
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 // beforeFormSave 이벤트 발송 (BomItemEditor 등 서브 컴포넌트의 저장 처리)
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
if (onSave) {
try {
await onSave();
return true;
} catch (error) {
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
throw error;
}
}
console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행");
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
// skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음
// 🔧 디버그: beforeFormSave 이벤트 전 formData 확인
console.log("🔍 [handleSave] beforeFormSave 이벤트 전:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
});
const beforeSaveEventDetail = { const beforeSaveEventDetail = {
formData: context.formData, formData: context.formData,
skipDefaultSave: false, skipDefaultSave: false,
@ -596,22 +572,28 @@ export class ButtonActionExecutor {
}), }),
); );
// 비동기 핸들러가 등록한 Promise들 대기 + 동기 핸들러를 위한 최소 대기
if (beforeSaveEventDetail.pendingPromises.length > 0) { if (beforeSaveEventDetail.pendingPromises.length > 0) {
console.log(
`[handleSave] 비동기 beforeFormSave 핸들러 ${beforeSaveEventDetail.pendingPromises.length}건 대기 중...`,
);
await Promise.all(beforeSaveEventDetail.pendingPromises); await Promise.all(beforeSaveEventDetail.pendingPromises);
} else { } else {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
} }
// 검증 실패 시 저장 중단
if (beforeSaveEventDetail.validationFailed) { if (beforeSaveEventDetail.validationFailed) {
console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors); console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors);
return false; return false;
} }
// EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
if (onSave) {
try {
await onSave();
return true;
} catch (error) {
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
throw error;
}
}
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리) // 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림 // EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
@ -1893,7 +1875,11 @@ export class ButtonActionExecutor {
mainFormDataKeys: Object.keys(mainFormData), mainFormDataKeys: Object.keys(mainFormData),
}); });
// V2Repeater 저장 완료를 기다리기 위한 Promise // V2Repeater가 등록된 경우에만 저장 완료를 기다림
// @ts-ignore
const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasActiveRepeaters) {
const repeaterSavePromise = new Promise<void>((resolve) => { const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000); const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => { const handler = () => {
@ -1916,6 +1902,7 @@ export class ButtonActionExecutor {
); );
await repeaterSavePromise; await repeaterSavePromise;
}
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행) // 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
context.onRefresh?.(); context.onRefresh?.();
@ -1951,6 +1938,10 @@ export class ButtonActionExecutor {
formDataKeys: Object.keys(formData), formDataKeys: Object.keys(formData),
}); });
// @ts-ignore
const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasActiveRepeaters) {
const repeaterSavePromise = new Promise<void>((resolve) => { const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000); const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => { const handler = () => {
@ -1973,7 +1964,7 @@ export class ButtonActionExecutor {
); );
await repeaterSavePromise; await repeaterSavePromise;
console.log("✅ [dispatchRepeaterSave] repeaterSave 완료"); }
} }
/** /**

View File

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

View File

@ -153,10 +153,12 @@ export interface CommonStyle {
// 라벨 스타일 // 라벨 스타일
labelDisplay?: boolean; // 라벨 표시 여부 labelDisplay?: boolean; // 라벨 표시 여부
labelText?: string; // 라벨 텍스트 labelText?: string; // 라벨 텍스트
labelPosition?: "top" | "left" | "right" | "bottom"; // 라벨 위치 (기본: top)
labelFontSize?: string; labelFontSize?: string;
labelColor?: string; labelColor?: string;
labelFontWeight?: string; labelFontWeight?: string;
labelMarginBottom?: string; labelMarginBottom?: string;
labelGap?: string; // 라벨-위젯 간격 (좌/우 배치 시 사용)
// 레이아웃 // 레이아웃
display?: string; display?: string;