Compare commits
25 Commits
d04dc4c050
...
a0e3147b47
| Author | SHA1 | Date |
|---|---|---|
|
|
a0e3147b47 | |
|
|
b1831ada04 | |
|
|
649bd77bbb | |
|
|
8bfc2ba4f5 | |
|
|
c1f7f27005 | |
|
|
c86337832a | |
|
|
d686c385e0 | |
|
|
0f52c3adc2 | |
|
|
36bc33860f | |
|
|
1b7163ee1a | |
|
|
c0df38c7ba | |
|
|
4e997ae36b | |
|
|
929b68299a | |
|
|
bfc89501ba | |
|
|
d50f705c44 | |
|
|
708a0fbd1f | |
|
|
bbbdd31311 | |
|
|
38ade7562e | |
|
|
385a10e2e7 | |
|
|
afc66a4971 | |
|
|
c161957cfe | |
|
|
0e0d433ce3 | |
|
|
0ca031282b | |
|
|
f90bf63354 | |
|
|
7a97603106 |
|
|
@ -298,3 +298,4 @@ claude.md
|
|||
|
||||
# 개인 작업 문서 (popdocs)
|
||||
popdocs/
|
||||
.cursor/rules/popdocs-safety.mdc
|
||||
733
PLAN.MD
733
PLAN.MD
|
|
@ -1,404 +1,202 @@
|
|||
# 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성
|
||||
# 현재 구현 계획: pop-card-list 입력 필드/계산 필드 구조 개편
|
||||
|
||||
> **작성일**: 2026-02-10
|
||||
> **상태**: 코딩 완료 (방어 로직 패치 포함)
|
||||
> **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성
|
||||
> **작성일**: 2026-02-24
|
||||
> **상태**: 계획 완료, 코딩 대기
|
||||
> **목적**: 입력 필드 설정 단순화 + 본문 필드에 계산식 통합 + 기존 계산 필드 섹션 제거
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제 요약
|
||||
## 1. 변경 개요
|
||||
|
||||
pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가.
|
||||
### 배경
|
||||
- 기존: "입력 필드", "계산 필드", "담기 버튼" 3개가 별도 섹션으로 분리
|
||||
- 문제: 계산 필드가 본문 필드와 동일한 위치에 표시되어야 하는데 별도 영역에 있음
|
||||
- 문제: 입력 필드의 min/max 고정값은 비실용적 (실제로는 DB 컬럼 기준 제한이 필요)
|
||||
- 문제: step, columnName, sourceColumns, resultColumn 등 죽은 코드 존재
|
||||
|
||||
| # | 문제 | 심각도 | 영향 |
|
||||
|---|------|--------|------|
|
||||
| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 |
|
||||
| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 |
|
||||
| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 |
|
||||
| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 |
|
||||
### 목표
|
||||
1. **본문 필드에 계산식 지원 추가** - 필드별로 "DB 컬럼" 또는 "계산식" 선택
|
||||
2. **입력 필드 설정 단순화** - 고정 min/max 제거, 제한 기준 컬럼 방식으로 변경
|
||||
3. **기존 "계산 필드" 섹션 제거** - 본문 필드에 통합되므로 불필요
|
||||
4. **죽은 코드 정리**
|
||||
|
||||
---
|
||||
|
||||
## 2. 수정 대상 파일 (2개)
|
||||
## 2. 수정 대상 파일 (3개)
|
||||
|
||||
### 파일 A: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx`
|
||||
### 파일 A: `frontend/lib/registry/pop-components/types.ts`
|
||||
|
||||
**변경 유형**: 설정 UI 추가 3건
|
||||
#### 변경 A-1: CardFieldBinding 타입 확장
|
||||
|
||||
#### 변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래)
|
||||
|
||||
집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가.
|
||||
|
||||
**추가할 위치**: `{/* 집계 함수 + 대상 컬럼 */}` 블록 다음, `{/* 자동 새로고침 */}` 블록 이전
|
||||
|
||||
**추가할 코드** (약 50줄):
|
||||
|
||||
```tsx
|
||||
{/* 그룹핑 (차트용 X축 분류) */}
|
||||
{dataSource.aggregation && (
|
||||
<div>
|
||||
<Label className="text-xs">그룹핑 (X축)</Label>
|
||||
<Popover open={groupByOpen} onOpenChange={setGroupByOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={groupByOpen}
|
||||
disabled={loadingCols}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{dataSource.aggregation.groupBy?.length
|
||||
? dataSource.aggregation.groupBy.join(", ")
|
||||
: "없음 (단일 값)"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
컬럼을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{columns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
const current = dataSource.aggregation?.groupBy ?? [];
|
||||
const isSelected = current.includes(col.name);
|
||||
const newGroupBy = isSelected
|
||||
? current.filter((g) => g !== col.name)
|
||||
: [...current, col.name];
|
||||
onChange({
|
||||
...dataSource,
|
||||
aggregation: {
|
||||
...dataSource.aggregation!,
|
||||
groupBy: newGroupBy.length > 0 ? newGroupBy : undefined,
|
||||
},
|
||||
});
|
||||
setGroupByOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
dataSource.aggregation?.groupBy?.includes(col.name)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{col.name}</span>
|
||||
<span className="ml-1 text-muted-foreground">({col.type})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
차트에서 X축 카테고리로 사용됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**필요한 state 추가** (DataSourceEditor 내부, 기존 state 옆):
|
||||
|
||||
```tsx
|
||||
const [groupByOpen, setGroupByOpen] = useState(false);
|
||||
```
|
||||
|
||||
#### 변경 A-2: 차트 타입 설정에 xAxisColumn/yAxisColumn 추가 (라인 1187~1212 부근)
|
||||
|
||||
**추가할 위치**: `{item.subType === "chart" && (` 블록 내부, 차트 유형 Select 다음
|
||||
|
||||
**추가할 코드** (약 30줄):
|
||||
|
||||
```tsx
|
||||
{/* X축 컬럼 */}
|
||||
<div>
|
||||
<Label className="text-xs">X축 컬럼</Label>
|
||||
<Input
|
||||
value={item.chartConfig?.xAxisColumn ?? ""}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
...item,
|
||||
chartConfig: {
|
||||
...item.chartConfig,
|
||||
chartType: item.chartConfig?.chartType ?? "bar",
|
||||
xAxisColumn: e.target.value || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="groupBy 컬럼명 (비우면 자동)"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 변경 A-3: 통계 카드 타입에 카테고리 설정 UI 추가 (라인 1272 부근, 게이지 설정 다음)
|
||||
|
||||
**추가할 위치**: `{item.subType === "gauge" && (` 블록 다음에 새 블록 추가
|
||||
|
||||
**추가할 코드** (약 100줄): `StatCategoryEditor` 인라인 블록
|
||||
|
||||
```tsx
|
||||
{item.subType === "stat-card" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">카테고리 설정</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => {
|
||||
const currentCats = item.statConfig?.categories ?? [];
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: {
|
||||
...item.statConfig,
|
||||
categories: [
|
||||
...currentCats,
|
||||
{
|
||||
label: `카테고리 ${currentCats.length + 1}`,
|
||||
filter: { column: "", operator: "=", value: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
카테고리 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(item.statConfig?.categories ?? []).map((cat, catIdx) => (
|
||||
<div key={catIdx} className="space-y-1 rounded border p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
value={cat.label}
|
||||
onChange={(e) => {
|
||||
const newCats = [...(item.statConfig?.categories ?? [])];
|
||||
newCats[catIdx] = { ...cat, label: e.target.value };
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: { ...item.statConfig, categories: newCats },
|
||||
});
|
||||
}}
|
||||
placeholder="라벨 (예: 수주)"
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={cat.color ?? ""}
|
||||
onChange={(e) => {
|
||||
const newCats = [...(item.statConfig?.categories ?? [])];
|
||||
newCats[catIdx] = { ...cat, color: e.target.value || undefined };
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: { ...item.statConfig, categories: newCats },
|
||||
});
|
||||
}}
|
||||
placeholder="#색상코드"
|
||||
className="h-6 w-20 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive"
|
||||
onClick={() => {
|
||||
const newCats = (item.statConfig?.categories ?? []).filter(
|
||||
(_, i) => i !== catIdx
|
||||
);
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: { ...item.statConfig, categories: newCats },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* 필터 조건: 컬럼 / 연산자 / 값 */}
|
||||
<div className="flex items-center gap-1 text-[10px]">
|
||||
<Input
|
||||
value={cat.filter.column}
|
||||
onChange={(e) => {
|
||||
const newCats = [...(item.statConfig?.categories ?? [])];
|
||||
newCats[catIdx] = {
|
||||
...cat,
|
||||
filter: { ...cat.filter, column: e.target.value },
|
||||
};
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: { ...item.statConfig, categories: newCats },
|
||||
});
|
||||
}}
|
||||
placeholder="컬럼"
|
||||
className="h-6 w-20 text-[10px]"
|
||||
/>
|
||||
<Select
|
||||
value={cat.filter.operator}
|
||||
onValueChange={(val) => {
|
||||
const newCats = [...(item.statConfig?.categories ?? [])];
|
||||
newCats[catIdx] = {
|
||||
...cat,
|
||||
filter: { ...cat.filter, operator: val as FilterOperator },
|
||||
};
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: { ...item.statConfig, categories: newCats },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-16 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="=" className="text-xs">= 같음</SelectItem>
|
||||
<SelectItem value="!=" className="text-xs">!= 다름</SelectItem>
|
||||
<SelectItem value="like" className="text-xs">LIKE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={String(cat.filter.value ?? "")}
|
||||
onChange={(e) => {
|
||||
const newCats = [...(item.statConfig?.categories ?? [])];
|
||||
newCats[catIdx] = {
|
||||
...cat,
|
||||
filter: { ...cat.filter, value: e.target.value },
|
||||
};
|
||||
onUpdate({
|
||||
...item,
|
||||
statConfig: { ...item.statConfig, categories: newCats },
|
||||
});
|
||||
}}
|
||||
placeholder="값"
|
||||
className="h-6 flex-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(item.statConfig?.categories ?? []).length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 파일 B: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx`
|
||||
|
||||
**변경 유형**: 데이터 처리 로직 수정 2건
|
||||
|
||||
#### 변경 B-1: 차트 xAxisColumn 자동 설정 (라인 276~283 부근)
|
||||
|
||||
차트에 groupBy가 있지만 xAxisColumn이 설정되지 않은 경우, 첫 번째 groupBy 컬럼을 자동으로 xAxisColumn에 반영.
|
||||
|
||||
**현재 코드** (라인 276~283):
|
||||
```tsx
|
||||
case "chart":
|
||||
return (
|
||||
<ChartItemComponent
|
||||
item={item}
|
||||
rows={itemData.rows}
|
||||
containerWidth={containerWidth}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
**변경 코드**:
|
||||
```tsx
|
||||
case "chart": {
|
||||
// groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정
|
||||
const chartItem = { ...item };
|
||||
if (
|
||||
item.dataSource.aggregation?.groupBy?.length &&
|
||||
!item.chartConfig?.xAxisColumn
|
||||
) {
|
||||
chartItem.chartConfig = {
|
||||
...chartItem.chartConfig,
|
||||
chartType: chartItem.chartConfig?.chartType ?? "bar",
|
||||
xAxisColumn: item.dataSource.aggregation.groupBy[0],
|
||||
};
|
||||
}
|
||||
return (
|
||||
<ChartItemComponent
|
||||
item={chartItem}
|
||||
rows={itemData.rows}
|
||||
containerWidth={containerWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297)
|
||||
|
||||
**현재 코드** (버그):
|
||||
```tsx
|
||||
case "stat-card": {
|
||||
const categoryData: Record<string, number> = {};
|
||||
if (item.statConfig?.categories) {
|
||||
for (const cat of item.statConfig.categories) {
|
||||
categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값
|
||||
}
|
||||
}
|
||||
return (
|
||||
<StatCardComponent item={item} categoryData={categoryData} />
|
||||
);
|
||||
**현재 코드** (라인 367~372):
|
||||
```typescript
|
||||
export interface CardFieldBinding {
|
||||
id: string;
|
||||
columnName: string;
|
||||
label: string;
|
||||
textColor?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**변경 코드**:
|
||||
```tsx
|
||||
case "stat-card": {
|
||||
const categoryData: Record<string, number> = {};
|
||||
if (item.statConfig?.categories) {
|
||||
for (const cat of item.statConfig.categories) {
|
||||
if (cat.filter.column && cat.filter.value) {
|
||||
// 카테고리 필터로 rows 필터링
|
||||
const filtered = itemData.rows.filter((row) => {
|
||||
const cellValue = String(row[cat.filter.column] ?? "");
|
||||
const filterValue = String(cat.filter.value ?? "");
|
||||
switch (cat.filter.operator) {
|
||||
case "=":
|
||||
return cellValue === filterValue;
|
||||
case "!=":
|
||||
return cellValue !== filterValue;
|
||||
case "like":
|
||||
return cellValue.toLowerCase().includes(filterValue.toLowerCase());
|
||||
default:
|
||||
return cellValue === filterValue;
|
||||
}
|
||||
});
|
||||
categoryData[cat.label] = filtered.length;
|
||||
} else {
|
||||
categoryData[cat.label] = itemData.rows.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<StatCardComponent item={item} categoryData={categoryData} />
|
||||
);
|
||||
```typescript
|
||||
export interface CardFieldBinding {
|
||||
id: string;
|
||||
label: string;
|
||||
textColor?: string;
|
||||
valueType: "column" | "formula"; // 값 유형: DB 컬럼 또는 계산식
|
||||
columnName?: string; // valueType === "column"일 때 사용
|
||||
formula?: string; // valueType === "formula"일 때 사용 (예: "$input - received_qty")
|
||||
unit?: string; // 계산식일 때 단위 표시 (예: "EA")
|
||||
}
|
||||
```
|
||||
|
||||
**주의**: 이 방식은 rows에 전체 데이터가 있어야 작동함. 통계 카드의 dataSource에 집계 함수를 설정하지 않고(또는 groupBy를 사용하여) 원시 rows를 가져와야 한다.
|
||||
**주의**: `columnName`이 required에서 optional로 변경됨. 기존 저장 데이터와의 하위 호환 필요.
|
||||
|
||||
#### 변경 A-2: CardInputFieldConfig 단순화
|
||||
|
||||
**현재 코드** (라인 443~453):
|
||||
```typescript
|
||||
export interface CardInputFieldConfig {
|
||||
enabled: boolean;
|
||||
columnName?: string;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
defaultValue?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
maxColumn?: string;
|
||||
step?: number;
|
||||
}
|
||||
```
|
||||
|
||||
**변경 코드**:
|
||||
```typescript
|
||||
export interface CardInputFieldConfig {
|
||||
enabled: boolean;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
limitColumn?: string; // 제한 기준 컬럼 (해당 행의 이 컬럼 값이 최대값)
|
||||
saveTable?: string; // 저장 대상 테이블
|
||||
saveColumn?: string; // 저장 대상 컬럼
|
||||
showPackageUnit?: boolean; // 포장등록 버튼 표시 여부
|
||||
}
|
||||
```
|
||||
|
||||
**제거 항목**:
|
||||
- `columnName` -> `saveTable` + `saveColumn`으로 대체 (명확한 네이밍)
|
||||
- `defaultValue` -> 제거 (제한 기준 컬럼 값으로 대체)
|
||||
- `min` -> 제거 (항상 0)
|
||||
- `max` -> 제거 (`limitColumn`으로 대체)
|
||||
- `maxColumn` -> `limitColumn`으로 이름 변경
|
||||
- `step` -> 제거 (키패드 방식에서 미사용)
|
||||
|
||||
#### 변경 A-3: CardCalculatedFieldConfig 제거
|
||||
|
||||
**삭제**: `CardCalculatedFieldConfig` 인터페이스 전체 (라인 457~464)
|
||||
**삭제**: `PopCardListConfig`에서 `calculatedField?: CardCalculatedFieldConfig;` 제거
|
||||
|
||||
---
|
||||
|
||||
### 파일 B: `frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx`
|
||||
|
||||
#### 변경 B-1: 본문 필드 편집기(FieldEditor)에 값 유형 선택 추가
|
||||
|
||||
**현재**: 필드 편집 시 라벨, 컬럼, 텍스트색상만 설정 가능
|
||||
|
||||
**변경**: 값 유형 라디오("DB 컬럼" / "계산식") 추가
|
||||
- "DB 컬럼" 선택 시: 기존 컬럼 Select 표시
|
||||
- "계산식" 선택 시: 수식 입력란 + 사용 가능한 컬럼/변수 칩 목록 표시
|
||||
- 사용 가능한 변수: DB 컬럼명들 + `$input` (입력 필드 활성화 시)
|
||||
|
||||
**하위 호환**: 기존 저장 데이터에 `valueType`이 없으면 `"column"`으로 기본 처리
|
||||
|
||||
#### 변경 B-2: 입력 필드 설정 섹션 개편
|
||||
|
||||
**현재 설정 항목**: 라벨, 단위, 기본값, 최소/최대, 최대값 컬럼, 저장 컬럼
|
||||
|
||||
**변경 설정 항목**:
|
||||
```
|
||||
라벨 [입고 수량 ]
|
||||
단위 [EA ]
|
||||
제한 기준 컬럼 [ order_qty v ]
|
||||
저장 대상 테이블 [ 선택 v ]
|
||||
저장 대상 컬럼 [ 선택 v ]
|
||||
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
포장등록 버튼 [on/off]
|
||||
```
|
||||
|
||||
#### 변경 B-3: "계산 필드" 섹션 제거
|
||||
|
||||
**삭제**: `CalculatedFieldSettingsSection` 함수 전체
|
||||
**삭제**: 카드 템플릿 탭에서 "계산 필드" CollapsibleSection 제거
|
||||
|
||||
#### 변경 B-4: import 정리
|
||||
|
||||
**삭제**: `CardCalculatedFieldConfig` import
|
||||
**추가**: 없음 (기존 import 재사용)
|
||||
|
||||
---
|
||||
|
||||
### 파일 C: `frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx`
|
||||
|
||||
#### 변경 C-1: FieldRow에서 계산식 필드 지원
|
||||
|
||||
**현재**: `const value = row[field.columnName]` 로 DB 값만 표시
|
||||
|
||||
**변경**:
|
||||
```typescript
|
||||
function FieldRow({ field, row, scaled, inputValue }: {
|
||||
field: CardFieldBinding;
|
||||
row: RowData;
|
||||
scaled: ScaledConfig;
|
||||
inputValue?: number; // 입력 필드 값 (계산식에서 $input으로 참조)
|
||||
}) {
|
||||
const value = field.valueType === "formula" && field.formula
|
||||
? evaluateFormula(field.formula, row, inputValue ?? 0)
|
||||
: row[field.columnName ?? ""];
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**주의**: `inputValue`를 FieldRow까지 전달해야 하므로 CardItem -> FieldRow 경로에 prop 추가 필요
|
||||
|
||||
#### 변경 C-2: 계산식 필드 실시간 갱신
|
||||
|
||||
**현재**: 별도 `calculatedValue` useMemo가 `[calculatedField, row, inputValue]`에 반응
|
||||
|
||||
**변경**: FieldRow가 `inputValue` prop을 받으므로, `inputValue`가 변경될 때 계산식 필드가 자동으로 리렌더링됨. 별도 useMemo 불필요.
|
||||
|
||||
#### 변경 C-3: 기존 calculatedField 관련 코드 제거
|
||||
|
||||
**삭제 대상**:
|
||||
- `calculatedField` prop 전달 (CardItem)
|
||||
- `calculatedValue` useMemo
|
||||
- 계산 필드 렌더링 블록 (`{calculatedField?.enabled && calculatedValue !== null && (...)}`
|
||||
|
||||
#### 변경 C-4: 입력 필드 로직 단순화
|
||||
|
||||
**변경 대상**:
|
||||
- `effectiveMax`: `limitColumn` 사용, 미설정 시 999999 폴백
|
||||
- `defaultValue` 자동 초기화 로직 제거 (불필요)
|
||||
- `NumberInputModal`에 포장등록 on/off 전달
|
||||
|
||||
#### 변경 C-5: NumberInputModal에 포장등록 on/off 전달
|
||||
|
||||
**현재**: 포장등록 버튼 항상 표시
|
||||
**변경**: `showPackageUnit` prop 추가, false이면 포장등록 버튼 숨김
|
||||
|
||||
---
|
||||
|
||||
### 파일 D: `frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx`
|
||||
|
||||
#### 변경 D-1: showPackageUnit prop 추가
|
||||
|
||||
**현재 props**: open, onOpenChange, unit, initialValue, initialPackageUnit, min, maxValue, onConfirm
|
||||
|
||||
**추가 prop**: `showPackageUnit?: boolean` (기본값 true)
|
||||
|
||||
**변경**: `showPackageUnit === false`이면 포장등록 버튼 숨김
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -406,20 +204,21 @@ case "stat-card": {
|
|||
|
||||
| 순서 | 작업 | 파일 | 의존성 | 상태 |
|
||||
|------|------|------|--------|------|
|
||||
| 1 | A-1: DataSourceEditor에 groupBy UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
||||
| 2 | A-2: 차트 xAxisColumn 입력 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
||||
| 3 | A-3: 통계 카드 카테고리 설정 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
||||
| 4 | B-1: 차트 xAxisColumn 자동 보정 로직 | PopDashboardComponent.tsx | 순서 1 | [x] |
|
||||
| 5 | B-2: 통계 카드 카테고리별 필터 적용 | PopDashboardComponent.tsx | 순서 3 | [x] |
|
||||
| 6 | 린트 검사 | 전체 | 순서 1~5 | [x] |
|
||||
| 7 | C-1: SQL 빌더 방어 로직 (빈 컬럼/테이블 차단) | dataFetcher.ts | 없음 | [x] |
|
||||
| 8 | C-2: refreshInterval 최소값 강제 (5초) | PopDashboardComponent.tsx | 없음 | [x] |
|
||||
| 9 | 브라우저 테스트 | - | 순서 1~8 | [ ] |
|
||||
| 1 | A-1: CardFieldBinding 타입 확장 | types.ts | 없음 | [ ] |
|
||||
| 2 | A-2: CardInputFieldConfig 단순화 | types.ts | 없음 | [ ] |
|
||||
| 3 | A-3: CardCalculatedFieldConfig 제거 | types.ts | 없음 | [ ] |
|
||||
| 4 | B-1: FieldEditor에 값 유형 선택 추가 | PopCardListConfig.tsx | 순서 1 | [ ] |
|
||||
| 5 | B-2: 입력 필드 설정 섹션 개편 | PopCardListConfig.tsx | 순서 2 | [ ] |
|
||||
| 6 | B-3: 계산 필드 섹션 제거 | PopCardListConfig.tsx | 순서 3 | [ ] |
|
||||
| 7 | B-4: import 정리 | PopCardListConfig.tsx | 순서 6 | [ ] |
|
||||
| 8 | D-1: NumberInputModal showPackageUnit 추가 | NumberInputModal.tsx | 없음 | [ ] |
|
||||
| 9 | C-1: FieldRow 계산식 지원 | PopCardListComponent.tsx | 순서 1 | [ ] |
|
||||
| 10 | C-3: calculatedField 관련 코드 제거 | PopCardListComponent.tsx | 순서 9 | [ ] |
|
||||
| 11 | C-4: 입력 필드 로직 단순화 | PopCardListComponent.tsx | 순서 2, 8 | [ ] |
|
||||
| 12 | 린트 검사 | 전체 | 순서 1~11 | [ ] |
|
||||
|
||||
순서 1, 2, 3은 서로 독립이므로 병렬 가능.
|
||||
순서 4는 순서 1의 groupBy 값이 있어야 의미 있음.
|
||||
순서 5는 순서 3의 카테고리 설정이 있어야 의미 있음.
|
||||
순서 7, 8은 백엔드 부하 방지를 위한 방어 패치.
|
||||
순서 1, 2, 3은 독립이므로 병렬 가능.
|
||||
순서 8은 독립이므로 병렬 가능.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -429,28 +228,21 @@ case "stat-card": {
|
|||
|
||||
| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 |
|
||||
|--------|------|-----------|-----------|-----------|
|
||||
| `groupByOpen` | state (boolean) | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 |
|
||||
| `setGroupByOpen` | state setter | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 |
|
||||
| `chartItem` | const (DashboardItem) | PopDashboardComponent.tsx renderSingleItem 내부 | 동일 함수 내부 | 충돌 없음 |
|
||||
|
||||
**Grep 검색 결과** (전체 pop-dashboard 폴더):
|
||||
- `groupByOpen`: 0건 - 충돌 없음
|
||||
- `setGroupByOpen`: 0건 - 충돌 없음
|
||||
- `groupByColumns`: 0건 - 충돌 없음
|
||||
- `chartItem`: 0건 - 충돌 없음
|
||||
- `StatCategoryEditor`: 0건 - 충돌 없음
|
||||
- `loadCategoryData`: 0건 - 충돌 없음
|
||||
| `valueType` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 |
|
||||
| `formula` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 (기존 CardCalculatedFieldConfig.formula와 다른 인터페이스) |
|
||||
| `limitColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 |
|
||||
| `saveTable` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 |
|
||||
| `saveColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 |
|
||||
| `showPackageUnit` | CardInputFieldConfig 속성 / NumberInputModal prop | types.ts, NumberInputModal.tsx | PopCardListComponent.tsx | 충돌 없음 |
|
||||
|
||||
### 기존 타입/함수 재사용 목록
|
||||
|
||||
| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 |
|
||||
|------------|-----------|------------------------|
|
||||
| `DataSourceConfig.aggregation.groupBy` | types.ts 라인 155 | A-1 UI에서 읽기/쓰기 |
|
||||
| `ChartItemConfig.xAxisColumn` | types.ts 라인 248 | A-2 UI, B-1 자동 보정 |
|
||||
| `StatCategory` | types.ts 라인 261 | A-3 카테고리 편집 |
|
||||
| `StatCardConfig.categories` | types.ts 라인 268 | A-3 UI에서 읽기/쓰기 |
|
||||
| `FilterOperator` | types.ts (import 이미 존재) | A-3 카테고리 필터 Select |
|
||||
| `columns` (state) | PopDashboardConfig.tsx DataSourceEditor 내부 | A-1 groupBy 컬럼 목록 |
|
||||
| `evaluateFormula()` | PopCardListComponent.tsx 라인 1026 | C-1: FieldRow에서 호출 (기존 함수 그대로 재사용) |
|
||||
| `CardFieldBinding` | types.ts 라인 367 | A-1에서 수정, B-1/C-1에서 사용 |
|
||||
| `CardInputFieldConfig` | types.ts 라인 443 | A-2에서 수정, B-2/C-4에서 사용 |
|
||||
| `GroupedColumnSelect` | PopCardListConfig.tsx | B-1: 계산식 모드에서 컬럼 칩 표시에 재사용 가능 |
|
||||
|
||||
**사용처 있는데 정의 누락된 항목: 없음**
|
||||
|
||||
|
|
@ -458,61 +250,81 @@ case "stat-card": {
|
|||
|
||||
## 5. 에러 함정 경고
|
||||
|
||||
### 함정 1: 차트에 groupBy만 설정하고 xAxisColumn을 비우면
|
||||
ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `{status: "수주", value: 79}` 형태.
|
||||
`name` 키가 없으므로 X축이 빈 채로 렌더링됨.
|
||||
**B-1의 자동 보정 로직이 필수**. 순서 4를 빠뜨리면 차트가 깨짐.
|
||||
### 함정 1: 기존 저장 데이터 하위 호환
|
||||
기존에 저장된 `CardFieldBinding`에는 `valueType`이 없고 `columnName`이 필수였음.
|
||||
**반드시** 런타임에서 `field.valueType || "column"` 폴백 처리해야 함.
|
||||
Config UI에서도 `valueType` 미존재 시 `"column"` 기본값 적용 필요.
|
||||
|
||||
### 함정 2: 통계 카드에 집계 함수를 설정하면
|
||||
집계(COUNT 등)가 설정되면 rows에 `[{value: 87}]` 하나만 들어옴.
|
||||
카테고리별 필터링이 작동하려면 **집계 함수를 "없음"으로 두거나, groupBy를 설정**해야 개별 행이 rows에 포함됨.
|
||||
통계 카드에서는 **집계를 사용하지 않는 것이 올바른 사용법**.
|
||||
설정 가이드 문서에 이 점을 명시해야 함.
|
||||
### 함정 2: CardInputFieldConfig 하위 호환
|
||||
기존 `maxColumn`이 `limitColumn`으로 이름 변경됨.
|
||||
기존 저장 데이터의 `maxColumn`을 `limitColumn`으로 읽어야 함.
|
||||
런타임: `inputField?.limitColumn || (inputField as any)?.maxColumn` 폴백 필요.
|
||||
|
||||
### 함정 3: PopDashboardConfig.tsx의 import 누락
|
||||
현재 `FilterOperator`는 이미 import되어 있음 (라인 54).
|
||||
`StatCategory`는 직접 사용하지 않고 `item.statConfig.categories` 구조로 접근하므로 import 불필요.
|
||||
**새로운 import 추가 필요 없음.**
|
||||
### 함정 3: evaluateFormula의 inputValue 전달
|
||||
FieldRow에 `inputValue`를 전달하려면, CardItem -> body.fields.map -> FieldRow 경로에서 `inputValue` prop을 추가해야 함.
|
||||
입력 필드가 비활성화된 경우 `inputValue`는 0으로 전달.
|
||||
|
||||
### 함정 4: 통계 카드 카테고리 필터에서 숫자 비교
|
||||
`String(row[col])` vs `String(filter.value)` 비교이므로, 숫자 컬럼도 문자열로 비교됨.
|
||||
`"100" === "100"`은 정상 동작하지만, `"100.00" !== "100"`이 될 수 있음.
|
||||
현재 대부분 컬럼이 varchar이므로 문제없지만, numeric 컬럼 사용 시 주의.
|
||||
### 함정 4: calculatedField 제거 시 기존 데이터
|
||||
기존 config에 `calculatedField` 데이터가 남아 있을 수 있음.
|
||||
타입에서 제거하더라도 런타임 에러는 나지 않음 (unknown 속성은 무시됨).
|
||||
다만 이전에 계산 필드로 설정한 내용은 사라짐 - 마이그레이션 없이 제거.
|
||||
|
||||
### 함정 5: DataSourceEditor의 columns state 타이밍
|
||||
`groupByOpen` Popover에서 `columns` 배열을 사용하는데, 테이블 선택 직후 columns가 아직 로딩 중일 수 있음.
|
||||
기존 코드에서 `loadingCols` 상태로 버튼을 disabled 처리하고 있으므로 문제없음.
|
||||
### 함정 5: columnName optional 변경
|
||||
`CardFieldBinding.columnName`이 optional이 됨.
|
||||
기존에 `row[field.columnName]`으로 직접 접근하던 코드 전부 수정 필요.
|
||||
`field.columnName ?? ""` 또는 valueType 분기 처리.
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 방법
|
||||
|
||||
### 차트 (BUG-1, BUG-2)
|
||||
1. 아이템 추가 > "차트" 선택
|
||||
2. 테이블: `sales_order_mng`, 집계: COUNT, 컬럼: `id`, 그룹핑: `status`
|
||||
3. 차트 유형: 막대 차트
|
||||
4. 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1
|
||||
### 시나리오 1: 기존 본문 필드 (하위 호환)
|
||||
1. 기존 저장된 카드리스트 열기
|
||||
2. 본문 필드에 기존 DB 컬럼 필드가 정상 표시되는지 확인
|
||||
3. 설정 패널에서 기존 필드가 "DB 컬럼" 유형으로 표시되는지 확인
|
||||
|
||||
### 통계 카드 (BUG-3, BUG-4)
|
||||
1. 아이템 추가 > "통계 카드" 선택
|
||||
2. 테이블: `sales_order_mng`, **집계: 없음** (중요!)
|
||||
3. 카테고리 추가:
|
||||
- "수주" / status / = / 수주
|
||||
- "진행중" / status / = / 진행중
|
||||
- "완료" / status / = / 완료
|
||||
4. 기대 결과: 수주 79, 진행중 7, 완료 1
|
||||
### 시나리오 2: 계산식 본문 필드 추가
|
||||
1. 본문 필드 추가 -> 값 유형 "계산식" 선택
|
||||
2. 수식: `order_qty - received_qty` 입력
|
||||
3. 카드에서 계산 결과가 정상 표시되는지 확인
|
||||
|
||||
### 시나리오 3: $input 참조 계산식
|
||||
1. 입력 필드 활성화
|
||||
2. 본문 필드 추가 -> 값 유형 "계산식" -> 수식: `$input - received_qty`
|
||||
3. 키패드에서 수량 입력 시 계산 결과가 실시간 갱신되는지 확인
|
||||
|
||||
### 시나리오 4: 제한 기준 컬럼
|
||||
1. 입력 필드 -> 제한 기준 컬럼: `order_qty`
|
||||
2. order_qty=1000인 카드에서 키패드 열기
|
||||
3. MAX 버튼 클릭 시 1000이 입력되고, 1001 이상 입력 불가 확인
|
||||
|
||||
### 시나리오 5: 포장등록 on/off
|
||||
1. 입력 필드 -> 포장등록 버튼: off
|
||||
2. 키패드 모달에서 포장등록 버튼이 숨겨지는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 이전 완료 계획 (아카이브)
|
||||
|
||||
<details>
|
||||
<summary>pop-dashboard 4가지 아이템 모드 완성 (완료)</summary>
|
||||
|
||||
- [x] groupBy UI 추가
|
||||
- [x] xAxisColumn 입력 UI 추가
|
||||
- [x] 통계카드 카테고리 설정 UI 추가
|
||||
- [x] 차트 xAxisColumn 자동 보정 로직
|
||||
- [x] 통계카드 카테고리별 필터 적용
|
||||
- [x] SQL 빌더 방어 로직
|
||||
- [x] refreshInterval 최소값 강제
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>POP 뷰어 스크롤 수정 (완료)</summary>
|
||||
|
||||
- [x] 라인 185: overflow-hidden 제거
|
||||
- [x] 라인 266: overflow-auto 공통 적용
|
||||
- [x] 라인 275: 일반 모드 min-h-full 추가
|
||||
- [x] 린트 검사 통과
|
||||
- [x] overflow-hidden 제거
|
||||
- [x] overflow-auto 공통 적용
|
||||
- [x] 일반 모드 min-h-full 추가
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -521,28 +333,5 @@ ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `
|
|||
|
||||
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가
|
||||
- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체
|
||||
- [x] 린트 검사 통과
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>V2/V2 컴포넌트 설정 스키마 정비 (완료)</summary>
|
||||
|
||||
- [x] 레거시 컴포넌트 스키마 제거
|
||||
- [x] V2 컴포넌트 overrides 스키마 정의 (16개)
|
||||
- [x] V2 컴포넌트 overrides 스키마 정의 (9개)
|
||||
- [x] componentConfig.ts 한 파일에서 통합 관리
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>화면 복제 기능 개선 (진행 중)</summary>
|
||||
|
||||
- [완료] DB 구조 개편 (menu_objid 의존성 제거)
|
||||
- [완료] 복제 옵션 정리
|
||||
- [완료] 화면 간 연결 복제 버그 수정
|
||||
- [대기] 화면 간 연결 복제 테스트
|
||||
- [대기] 제어관리 복제 테스트
|
||||
- [대기] 추가 옵션 복제 테스트
|
||||
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@
|
|||
|
||||
| 순서 | 작업 | 상태 |
|
||||
|------|------|------|
|
||||
| 1 | 브라우저 확인 (라벨 정렬 동작, 반응형 자동 크기, 미리보기 반영) | [ ] 대기 |
|
||||
| 2 | 게이지/통계카드 테스트 시나리오 동작 확인 | [ ] 대기 |
|
||||
| 3 | Phase 2 계획 수립 (pop-button, pop-icon) | [ ] 대기 |
|
||||
| 1 | pop-card-list 입력 필드/계산 필드 구조 개편 (PLAN.MD 참고) | [ ] 코딩 대기 |
|
||||
| 2 | pop-card-list 담기 버튼 독립화 (보류) | [ ] 대기 |
|
||||
| 3 | pop-card-list 반응형 표시 런타임 적용 | [ ] 대기 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -92,9 +92,9 @@ export async function createBomVersion(req: Request, res: Response) {
|
|||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
const { tableName, detailTable } = req.body || {};
|
||||
const { 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 });
|
||||
} catch (error: any) {
|
||||
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) {
|
||||
try {
|
||||
const { bomId, versionId } = req.params;
|
||||
|
|
|
|||
|
|
@ -17,9 +17,15 @@ router.get("/:bomId/header", bomController.getBomHeader);
|
|||
router.get("/:bomId/history", bomController.getBomHistory);
|
||||
router.post("/:bomId/history", bomController.addBomHistory);
|
||||
|
||||
// 엑셀 업로드/다운로드
|
||||
router.post("/excel-upload", bomController.createBomFromExcel);
|
||||
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
|
||||
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
|
||||
|
||||
// 버전
|
||||
router.get("/:bomId/versions", bomController.getBomVersions);
|
||||
router.post("/:bomId/versions", bomController.createBomVersion);
|
||||
router.post("/:bomId/initialize-version", bomController.initializeBomVersion);
|
||||
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
|
||||
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
|
||||
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
|
||||
|
|
|
|||
|
|
@ -59,7 +59,10 @@ export async function getBomHeader(bomId: string, tableName?: string) {
|
|||
const table = safeTableName(tableName || "", "bom");
|
||||
const sql = `
|
||||
SELECT b.*,
|
||||
i.item_name, i.item_number, i.division as item_type, i.unit
|
||||
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
|
||||
LEFT JOIN item_info i ON b.item_id = i.id
|
||||
WHERE b.id = $1
|
||||
|
|
@ -98,6 +101,7 @@ export async function getBomVersions(bomId: string, companyCode: string, tableNa
|
|||
export async function createBomVersion(
|
||||
bomId: string, companyCode: string, createdBy: string,
|
||||
versionTableName?: string, detailTableName?: string,
|
||||
inputVersionName?: string,
|
||||
) {
|
||||
const vTable = safeTableName(versionTableName || "", "bom_version");
|
||||
const dTable = safeTableName(detailTableName || "", "bom_detail");
|
||||
|
|
@ -107,17 +111,24 @@ export async function createBomVersion(
|
|||
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
|
||||
const bomData = bomRow.rows[0];
|
||||
|
||||
// 다음 버전 번호 결정
|
||||
const lastVersion = await client.query(
|
||||
`SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`,
|
||||
// 버전명: 사용자 입력 > 순번 자동 생성
|
||||
let versionName = inputVersionName?.trim();
|
||||
if (!versionName) {
|
||||
const countResult = await client.query(
|
||||
`SELECT COUNT(*)::int as cnt FROM ${vTable} WHERE bom_id = $1`,
|
||||
[bomId],
|
||||
);
|
||||
let nextVersionNum = 1;
|
||||
if (lastVersion.rows.length > 0) {
|
||||
const parsed = parseFloat(lastVersion.rows[0].version_name);
|
||||
if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1;
|
||||
versionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
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 없이)
|
||||
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 행도 함께 삭제
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
screenId: number,
|
||||
tableName: string,
|
||||
tableNameInput: string,
|
||||
data: Record<string, any>,
|
||||
ipAddress?: string
|
||||
): Promise<FormDataResult> {
|
||||
// VIEW인 경우 원본 테이블로 전환
|
||||
const tableName = await this.resolveBaseTable(tableNameInput);
|
||||
try {
|
||||
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
|
||||
screenId,
|
||||
tableName,
|
||||
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
|
||||
data,
|
||||
});
|
||||
|
||||
|
|
@ -813,14 +856,17 @@ export class DynamicFormService {
|
|||
*/
|
||||
async updateFormDataPartial(
|
||||
id: string | number, // 🔧 UUID 문자열도 지원
|
||||
tableName: string,
|
||||
tableNameInput: string,
|
||||
originalData: Record<string, any>,
|
||||
newData: Record<string, any>
|
||||
): Promise<PartialUpdateResult> {
|
||||
// VIEW인 경우 원본 테이블로 전환
|
||||
const tableName = await this.resolveBaseTable(tableNameInput);
|
||||
try {
|
||||
console.log("🔄 서비스: 부분 업데이트 시작:", {
|
||||
id,
|
||||
tableName,
|
||||
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
|
||||
originalData,
|
||||
newData,
|
||||
});
|
||||
|
|
@ -1008,13 +1054,16 @@ export class DynamicFormService {
|
|||
*/
|
||||
async updateFormData(
|
||||
id: string | number,
|
||||
tableName: string,
|
||||
tableNameInput: string,
|
||||
data: Record<string, any>
|
||||
): Promise<FormDataResult> {
|
||||
// VIEW인 경우 원본 테이블로 전환
|
||||
const tableName = await this.resolveBaseTable(tableNameInput);
|
||||
try {
|
||||
console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", {
|
||||
id,
|
||||
tableName,
|
||||
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
|
||||
data,
|
||||
});
|
||||
|
||||
|
|
@ -1033,6 +1082,9 @@ export class DynamicFormService {
|
|||
if (tableColumns.includes("updated_at")) {
|
||||
dataToUpdate.updated_at = new Date();
|
||||
}
|
||||
if (tableColumns.includes("updated_date")) {
|
||||
dataToUpdate.updated_date = new Date();
|
||||
}
|
||||
if (tableColumns.includes("regdate") && !dataToUpdate.regdate) {
|
||||
dataToUpdate.regdate = new Date();
|
||||
}
|
||||
|
|
@ -1212,9 +1264,13 @@ export class DynamicFormService {
|
|||
screenId?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// VIEW인 경우 원본 테이블로 전환 (VIEW에는 기본키가 없으므로)
|
||||
const actualTable = await this.resolveBaseTable(tableName);
|
||||
|
||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||
id,
|
||||
tableName,
|
||||
tableName: actualTable,
|
||||
originalTable: tableName !== actualTable ? tableName : undefined,
|
||||
});
|
||||
|
||||
// 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회
|
||||
|
|
@ -1232,15 +1288,15 @@ export class DynamicFormService {
|
|||
`;
|
||||
|
||||
console.log("🔍 기본키 조회 SQL:", primaryKeyQuery);
|
||||
console.log("🔍 테이블명:", tableName);
|
||||
console.log("🔍 테이블명:", actualTable);
|
||||
|
||||
const primaryKeyResult = await query<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
}>(primaryKeyQuery, [tableName]);
|
||||
}>(primaryKeyQuery, [actualTable]);
|
||||
|
||||
if (!primaryKeyResult || primaryKeyResult.length === 0) {
|
||||
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
|
||||
throw new Error(`테이블 ${actualTable}의 기본키를 찾을 수 없습니다.`);
|
||||
}
|
||||
|
||||
const primaryKeyInfo = primaryKeyResult[0];
|
||||
|
|
@ -1272,7 +1328,7 @@ export class DynamicFormService {
|
|||
|
||||
// 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성
|
||||
const deleteQuery = `
|
||||
DELETE FROM ${tableName}
|
||||
DELETE FROM ${actualTable}
|
||||
WHERE ${primaryKeyColumn} = $1${typeCastSuffix}
|
||||
RETURNING *
|
||||
`;
|
||||
|
|
@ -1292,7 +1348,7 @@ export class DynamicFormService {
|
|||
|
||||
// 삭제된 행이 없으면 레코드를 찾을 수 없는 것
|
||||
if (!result || !Array.isArray(result) || result.length === 0) {
|
||||
throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
|
||||
throw new Error(`테이블 ${actualTable}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
|
||||
}
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
|
|
@ -9,6 +9,7 @@ services:
|
|||
- "9771:3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8080/api
|
||||
- SERVER_API_URL=http://pms-backend-mac:8080
|
||||
- NODE_OPTIONS=--max-old-space-size=8192
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -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]로 표시
|
||||
- 현재 진행 중인 테스트는 [진행중]으로 표시
|
||||
|
|
@ -6,7 +6,7 @@ import { Loader2 } from "lucide-react";
|
|||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* /screen/COMPANY_7_167 → /screens/4153 리다이렉트
|
||||
* /screen/{screenCode} → /screens/{screenId} 리다이렉트
|
||||
* 메뉴 URL이 screenCode 기반이므로, screenId로 변환 후 이동
|
||||
*/
|
||||
export default function ScreenCodeRedirectPage() {
|
||||
|
|
@ -26,12 +26,14 @@ export default function ScreenCodeRedirectPage() {
|
|||
const resolve = async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/screen-management/screens", {
|
||||
params: { screenCode },
|
||||
params: { searchTerm: screenCode, size: 50 },
|
||||
});
|
||||
const screens = res.data?.data || [];
|
||||
if (screens.length > 0) {
|
||||
const id = screens[0].screenId || screens[0].screen_id;
|
||||
router.replace(`/screens/${id}`);
|
||||
const items = res.data?.data?.data || res.data?.data || [];
|
||||
const arr = Array.isArray(items) ? items : [];
|
||||
const exact = arr.find((s: any) => s.screenCode === screenCode);
|
||||
const target = exact || arr[0];
|
||||
if (target) {
|
||||
router.replace(`/screens/${target.screenId || target.screen_id}`);
|
||||
} else {
|
||||
router.replace("/");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
let dataToSave = { ...row };
|
||||
let shouldSkip = false;
|
||||
|
|
@ -925,15 +926,16 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
|
||||
if (existingDataMap.has(key)) {
|
||||
existingRow = existingDataMap.get(key);
|
||||
// 중복 발견 - 전역 설정에 따라 처리
|
||||
if (duplicateAction === "skip") {
|
||||
shouldSkip = true;
|
||||
skipCount++;
|
||||
console.log(`⏭️ 중복으로 건너뛰기: ${key}`);
|
||||
console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
|
||||
} else {
|
||||
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 hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
|
||||
|
||||
|
|
@ -968,24 +970,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
tableName,
|
||||
data: dataToSave,
|
||||
};
|
||||
console.log(`📝 [행 ${rowIdx + 1}] 덮어쓰기 시도: id=${existingRow.id}`, dataToSave);
|
||||
const result = await DynamicFormApi.updateFormData(existingRow.id, formData);
|
||||
if (result.success) {
|
||||
overwriteCount++;
|
||||
successCount++;
|
||||
} else {
|
||||
console.error(`❌ [행 ${rowIdx + 1}] 덮어쓰기 실패:`, result.message);
|
||||
failCount++;
|
||||
}
|
||||
} else if (uploadMode === "insert") {
|
||||
// 신규 등록
|
||||
} else if (uploadMode === "insert" || uploadMode === "upsert") {
|
||||
// 신규 등록 (insert, upsert 모드)
|
||||
const formData = { screenId: 0, tableName, data: dataToSave };
|
||||
console.log(`📝 [행 ${rowIdx + 1}] 신규 등록 시도 (mode: ${uploadMode}):`, dataToSave);
|
||||
const result = await DynamicFormApi.saveFormData(formData);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
console.log(`✅ [행 ${rowIdx + 1}] 신규 등록 성공`);
|
||||
} else {
|
||||
console.error(`❌ [행 ${rowIdx + 1}] 신규 등록 실패:`, result.message);
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
|
@ -1008,8 +1020,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
console.log(`📊 엑셀 업로드 결과 요약: 성공=${successCount}, 건너뛰기=${skipCount}, 덮어쓰기=${overwriteCount}, 실패=${failCount}`);
|
||||
|
||||
if (successCount > 0 || skipCount > 0) {
|
||||
// 상세 결과 메시지 생성
|
||||
let message = "";
|
||||
if (successCount > 0) {
|
||||
message += `${successCount}개 행 업로드`;
|
||||
|
|
@ -1022,15 +1035,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
message += `중복 건너뛰기 ${skipCount}개`;
|
||||
}
|
||||
if (failCount > 0) {
|
||||
message += ` (실패: ${failCount}개)`;
|
||||
message += `, 실패 ${failCount}개`;
|
||||
}
|
||||
|
||||
if (failCount > 0 && successCount === 0) {
|
||||
toast.warning(message);
|
||||
} else {
|
||||
toast.success(message);
|
||||
}
|
||||
|
||||
// 매핑 템플릿 저장
|
||||
await saveMappingTemplateInternal();
|
||||
|
||||
if (successCount > 0 || overwriteCount > 0) {
|
||||
onSuccess?.();
|
||||
}
|
||||
} else if (failCount > 0) {
|
||||
toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`);
|
||||
} else {
|
||||
toast.error("업로드에 실패했습니다.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,6 +213,8 @@ export default function ComponentEditorPanel({
|
|||
previewPageIndex={previewPageIndex}
|
||||
onPreviewPage={onPreviewPage}
|
||||
modals={modals}
|
||||
allComponents={allComponents}
|
||||
connections={connections}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
@ -404,9 +406,11 @@ interface ComponentSettingsFormProps {
|
|||
previewPageIndex?: number;
|
||||
onPreviewPage?: (pageIndex: number) => void;
|
||||
modals?: PopModalDefinition[];
|
||||
allComponents?: PopComponentDefinitionV5[];
|
||||
connections?: PopDataConnection[];
|
||||
}
|
||||
|
||||
function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) {
|
||||
function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals, allComponents, connections }: ComponentSettingsFormProps) {
|
||||
// PopComponentRegistry에서 configPanel 가져오기
|
||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||
const ConfigPanel = registeredComp?.configPanel;
|
||||
|
|
@ -440,6 +444,9 @@ function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIn
|
|||
onPreviewPage={onPreviewPage}
|
||||
previewPageIndex={previewPageIndex}
|
||||
modals={modals}
|
||||
allComponents={allComponents}
|
||||
connections={connections}
|
||||
componentId={component.id}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
|
|
@ -515,3 +522,4 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -272,6 +272,25 @@ function ConnectionForm({
|
|||
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
||||
: null;
|
||||
|
||||
// 보내는 값 + 받는 컴포넌트가 결정되면 받는 방식 자동 매칭
|
||||
React.useEffect(() => {
|
||||
if (!selectedOutput || !targetMeta?.receivable?.length) return;
|
||||
// 이미 선택된 값이 있으면 건드리지 않음
|
||||
if (selectedTargetInput) return;
|
||||
|
||||
const receivables = targetMeta.receivable;
|
||||
// 1) 같은 key가 있으면 자동 매칭
|
||||
const exactMatch = receivables.find((r) => r.key === selectedOutput);
|
||||
if (exactMatch) {
|
||||
setSelectedTargetInput(exactMatch.key);
|
||||
return;
|
||||
}
|
||||
// 2) receivable이 1개뿐이면 자동 선택
|
||||
if (receivables.length === 1) {
|
||||
setSelectedTargetInput(receivables[0].key);
|
||||
}
|
||||
}, [selectedOutput, targetMeta, selectedTargetInput]);
|
||||
|
||||
// 화면에 표시 중인 컬럼
|
||||
const displayColumns = React.useMemo(
|
||||
() => extractDisplayColumns(targetComp || undefined),
|
||||
|
|
@ -322,6 +341,8 @@ function ConnectionForm({
|
|||
const handleSubmit = () => {
|
||||
if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return;
|
||||
|
||||
const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput);
|
||||
|
||||
onSubmit({
|
||||
sourceComponent: component.id,
|
||||
sourceField: "",
|
||||
|
|
@ -330,7 +351,7 @@ function ConnectionForm({
|
|||
targetField: "",
|
||||
targetInput: selectedTargetInput,
|
||||
filterConfig:
|
||||
filterColumns.length > 0
|
||||
!isEvent && filterColumns.length > 0
|
||||
? {
|
||||
targetColumn: filterColumns[0],
|
||||
targetColumns: filterColumns,
|
||||
|
|
@ -427,8 +448,8 @@ function ConnectionForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 설정 */}
|
||||
{selectedTargetInput && (
|
||||
{/* 필터 설정: event 타입 연결이면 숨김 */}
|
||||
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
|
||||
<div className="space-y-2 rounded bg-gray-50 p-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
||||
|
||||
|
|
@ -607,6 +628,17 @@ function ReceiveSection({
|
|||
// 유틸
|
||||
// ========================================
|
||||
|
||||
function isEventTypeConnection(
|
||||
sourceMeta: ComponentConnectionMeta | undefined,
|
||||
outputKey: string,
|
||||
targetMeta: ComponentConnectionMeta | null | undefined,
|
||||
inputKey: string,
|
||||
): boolean {
|
||||
const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey);
|
||||
const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey);
|
||||
return sourceItem?.type === "event" || targetItem?.type === "event";
|
||||
}
|
||||
|
||||
function buildConnectionLabel(
|
||||
source: PopComponentDefinitionV5,
|
||||
_outputKey: string,
|
||||
|
|
|
|||
|
|
@ -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)에만 사용
|
||||
// INSERT/UPDATE 판단에는 사용하지 않음
|
||||
setOriginalData(isCreateMode ? {} : editData || {});
|
||||
|
|
@ -1211,7 +1230,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
|
||||
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
|
||||
const hasRepeaterForInsert = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
|
||||
if (hasRepeaterForInsert) {
|
||||
try {
|
||||
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||
|
|
@ -1223,11 +1244,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
window.addEventListener("repeaterSaveComplete", handler);
|
||||
});
|
||||
|
||||
console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", {
|
||||
parentId: masterRecordId,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
|
|
@ -1240,10 +1256,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
);
|
||||
|
||||
await repeaterSavePromise;
|
||||
console.log("✅ [EditModal] INSERT 후 repeaterSave 완료");
|
||||
} catch (repeaterError) {
|
||||
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
|
||||
}
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} else {
|
||||
|
|
@ -1251,8 +1267,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
} else {
|
||||
// UPDATE 모드 - PUT (전체 업데이트)
|
||||
// originalData 비교 없이 formData 전체를 보냄
|
||||
const recordId = formData.id;
|
||||
// VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조)
|
||||
const recordId = formData.master_id || formData.id;
|
||||
|
||||
if (!recordId) {
|
||||
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
|
||||
|
|
@ -1305,15 +1321,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
if (response.success) {
|
||||
toast.success("데이터가 수정되었습니다.");
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
if (modalState.onSave) {
|
||||
try {
|
||||
modalState.onSave();
|
||||
} catch (callbackError) {
|
||||
console.error("onSave 콜백 에러:", callbackError);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
|
||||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||
try {
|
||||
|
|
@ -1350,7 +1357,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
|
||||
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
|
||||
const hasRepeaterForUpdate = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
|
||||
if (hasRepeaterForUpdate) {
|
||||
try {
|
||||
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||
|
|
@ -1362,11 +1371,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
window.addEventListener("repeaterSaveComplete", handler);
|
||||
});
|
||||
|
||||
console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", {
|
||||
parentId: recordId,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
|
|
@ -1379,11 +1383,15 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
);
|
||||
|
||||
await repeaterSavePromise;
|
||||
console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료");
|
||||
} catch (repeaterError) {
|
||||
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
|
||||
}
|
||||
}
|
||||
|
||||
// 리피터 저장 완료 후 메인 테이블 새로고침
|
||||
if (modalState.onSave) {
|
||||
try { modalState.onSave(); } catch {}
|
||||
}
|
||||
handleClose();
|
||||
} else {
|
||||
throw new Error(response.message || "수정에 실패했습니다.");
|
||||
|
|
|
|||
|
|
@ -245,23 +245,29 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
|||
};
|
||||
|
||||
// 라벨 렌더링
|
||||
const labelPos = widget.style?.labelPosition || "top";
|
||||
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
||||
|
||||
const renderLabel = () => {
|
||||
if (hideLabel) return null;
|
||||
|
||||
const labelStyle = widget.style || {};
|
||||
const ls = widget.style || {};
|
||||
const labelElement = (
|
||||
<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={{
|
||||
fontSize: labelStyle.labelFontSize || "14px",
|
||||
color: hasError ? "hsl(var(--destructive))" : labelStyle.labelColor || undefined,
|
||||
fontWeight: labelStyle.labelFontWeight || "500",
|
||||
fontFamily: labelStyle.labelFontFamily,
|
||||
textAlign: labelStyle.labelTextAlign || "left",
|
||||
backgroundColor: labelStyle.labelBackgroundColor,
|
||||
padding: labelStyle.labelPadding,
|
||||
borderRadius: labelStyle.labelBorderRadius,
|
||||
marginBottom: labelStyle.labelMarginBottom || "8px",
|
||||
fontSize: ls.labelFontSize || "14px",
|
||||
color: hasError ? "hsl(var(--destructive))" : ls.labelColor || undefined,
|
||||
fontWeight: ls.labelFontWeight || "500",
|
||||
fontFamily: ls.labelFontFamily,
|
||||
textAlign: ls.labelTextAlign || "left",
|
||||
backgroundColor: ls.labelBackgroundColor,
|
||||
padding: ls.labelPadding,
|
||||
borderRadius: ls.labelBorderRadius,
|
||||
...(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}
|
||||
|
|
@ -332,11 +338,28 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
|||
}
|
||||
};
|
||||
|
||||
const labelElement = renderLabel();
|
||||
const widgetElement = renderByWebType();
|
||||
const validationElement = renderFieldValidation();
|
||||
|
||||
if (isHorizLabel && labelElement) {
|
||||
return (
|
||||
<div key={comp.id} className="space-y-2">
|
||||
{renderLabel()}
|
||||
{renderByWebType()}
|
||||
{renderFieldValidation()}
|
||||
<div key={comp.id}>
|
||||
<div style={{ display: "flex", flexDirection: labelPos === "left" ? "row" : "row-reverse", alignItems: "center", gap: widget.style?.labelGap || "8px" }}>
|
||||
{labelElement}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
color: component.style?.labelColor || "#212121",
|
||||
fontWeight: component.style?.labelFontWeight || "500",
|
||||
backgroundColor: component.style?.labelBackgroundColor || "transparent",
|
||||
padding: component.style?.labelPadding || "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 />
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className="h-full flex-1" style={{ width: '100%' }}>
|
||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||
{shouldShowLabel && (
|
||||
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{/* 메인 컨텐츠 - 라벨 위치에 따라 flex 방향 변경 */}
|
||||
<div
|
||||
className="h-full flex-1"
|
||||
style={{
|
||||
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}
|
||||
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -1103,17 +1103,21 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
// TableSearchWidget의 경우 높이를 자동으로 설정
|
||||
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||
|
||||
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트)
|
||||
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시
|
||||
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date";
|
||||
// 라벨 표시 여부 확인 (V2 입력 컴포넌트)
|
||||
const compType = (component as any).componentType || "";
|
||||
const isV2InputComponent =
|
||||
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
|
||||
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
|
||||
const hasVisibleLabel = isV2InputComponent &&
|
||||
style?.labelDisplay !== false &&
|
||||
(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 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 compType = (component as any).componentType || "";
|
||||
|
|
@ -1263,10 +1267,56 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
return unsubscribe;
|
||||
}, [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 (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* 팝업 화면 렌더링 */}
|
||||
|
|
|
|||
|
|
@ -841,6 +841,44 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</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="space-y-1">
|
||||
<Label className="text-xs">크기</Label>
|
||||
|
|
@ -862,12 +900,21 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">여백</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelMarginBottom || "4px"}
|
||||
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
<Label className="text-xs">굵기</Label>
|
||||
<Select
|
||||
value={selectedComponent.style?.labelFontWeight || "500"}
|
||||
onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
|
||||
>
|
||||
<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 className="flex items-center space-x-2 pt-5">
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -704,10 +704,56 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
|||
const componentWidth = size?.width || style?.width;
|
||||
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 labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
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 (
|
||||
<div
|
||||
|
|
@ -719,27 +765,8 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
|
|||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||
{showLabel && (
|
||||
<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>
|
||||
{labelElement}
|
||||
{dateContent}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -961,36 +961,83 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
}
|
||||
};
|
||||
|
||||
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
|
||||
// 🔧 label prop이 없어도 style.labelText에서 가져올 수 있도록 수정
|
||||
const actualLabel = label || style?.labelText;
|
||||
const showLabel = actualLabel && style?.labelDisplay === true;
|
||||
// size에서 우선 가져오고, 없으면 style에서 가져옴
|
||||
const componentWidth = size?.width || style?.width;
|
||||
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 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 hasCustomBackground = !!style?.backgroundColor;
|
||||
const hasCustomRadius = !!style?.borderRadius;
|
||||
|
||||
// 텍스트 스타일 오버라이드 (내부 input/textarea에 직접 전달)
|
||||
const customTextStyle: React.CSSProperties = {};
|
||||
if (style?.color) customTextStyle.color = style.color;
|
||||
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
|
||||
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
|
||||
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
|
||||
const hasCustomText = Object.keys(customTextStyle).length > 0;
|
||||
// 내부 input에 직접 적용할 텍스트 스타일 (fontSize, color, fontWeight, textAlign)
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -1001,38 +1048,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */}
|
||||
{showLabel && (
|
||||
<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>
|
||||
{labelElement}
|
||||
{inputContent}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,9 +50,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
formData: parentFormData,
|
||||
...restProps
|
||||
}) => {
|
||||
// ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용)
|
||||
const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData;
|
||||
|
||||
// componentId 결정: 직접 전달 또는 component 객체에서 추출
|
||||
const effectiveComponentId = componentId || (restProps as any).component?.id;
|
||||
|
||||
|
|
@ -214,21 +211,20 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
const isModalMode = config.renderMode === "modal";
|
||||
|
||||
// 전역 리피터 등록
|
||||
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블)
|
||||
// tableName이 비어있어도 반드시 등록 (repeaterSave 이벤트 발행 가드에 필요)
|
||||
useEffect(() => {
|
||||
const targetTableName =
|
||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||
const registrationKey = targetTableName || "__v2_repeater_same_table__";
|
||||
|
||||
if (targetTableName) {
|
||||
if (!window.__v2RepeaterInstances) {
|
||||
window.__v2RepeaterInstances = new Set();
|
||||
}
|
||||
window.__v2RepeaterInstances.add(targetTableName);
|
||||
}
|
||||
window.__v2RepeaterInstances.add(registrationKey);
|
||||
|
||||
return () => {
|
||||
if (targetTableName && window.__v2RepeaterInstances) {
|
||||
window.__v2RepeaterInstances.delete(targetTableName);
|
||||
if (window.__v2RepeaterInstances) {
|
||||
window.__v2RepeaterInstances.delete(registrationKey);
|
||||
}
|
||||
};
|
||||
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
|
||||
|
|
@ -428,7 +424,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
{
|
||||
page: 1,
|
||||
size: 1000,
|
||||
search: { [config.foreignKeyColumn]: fkValue },
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }],
|
||||
},
|
||||
autoFilter: true,
|
||||
}
|
||||
);
|
||||
|
|
@ -965,90 +964,8 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
[],
|
||||
);
|
||||
|
||||
// 모달에서 전달된 groupedData를 초기 행 데이터로 변환 (컬럼 매핑 포함)
|
||||
const groupedDataProcessedRef = useRef(false);
|
||||
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]);
|
||||
// V2Repeater는 자체 데이터 관리 (아이템 선택 모달, useCustomTable 로딩, DataReceiver)를 사용.
|
||||
// EditModal의 groupedData는 메인 테이블 레코드이므로 V2Repeater에서는 사용하지 않음.
|
||||
|
||||
// parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options
|
||||
.filter((option) => option.value !== "")
|
||||
.filter((option) => option.value != null && option.value !== "")
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
@ -112,6 +112,12 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
}
|
||||
|
||||
// 검색 가능 또는 다중 선택 → Combobox 사용
|
||||
// null/undefined value를 가진 옵션 필터링 (cmdk가 value={null}일 때 크래시 발생)
|
||||
const safeOptions = useMemo(() =>
|
||||
options.filter((o) => o.value != null && o.value !== ""),
|
||||
[options]
|
||||
);
|
||||
|
||||
const selectedValues = useMemo(() => {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
|
|
@ -119,9 +125,9 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
|
||||
const selectedLabels = useMemo(() => {
|
||||
return selectedValues
|
||||
.map((v) => options.find((o) => o.value === v)?.label)
|
||||
.map((v) => safeOptions.find((o) => o.value === v)?.label)
|
||||
.filter(Boolean) as string[];
|
||||
}, [selectedValues, options]);
|
||||
}, [selectedValues, safeOptions]);
|
||||
|
||||
const handleSelect = useCallback((selectedValue: string) => {
|
||||
if (multiple) {
|
||||
|
|
@ -191,7 +197,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
<Command
|
||||
filter={(itemValue, search) => {
|
||||
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();
|
||||
if (label.includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
|
|
@ -201,7 +207,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
{safeOptions.map((option) => {
|
||||
const displayLabel = option.label || option.value || "(빈 값)";
|
||||
return (
|
||||
<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);
|
||||
} catch (error) {
|
||||
console.error("옵션 로딩 실패:", error);
|
||||
|
|
@ -882,6 +892,42 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
loadOptions();
|
||||
}, [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) 컬럼을 사용하는 다른 컴포넌트 자동 감지
|
||||
const autoFillTargets = useMemo(() => {
|
||||
if (source !== "entity" || !entityTable || !allComponents) return [];
|
||||
|
|
@ -1007,7 +1053,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<DropdownSelect
|
||||
options={options}
|
||||
value={value}
|
||||
value={resolvedValue}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
placeholder="선택"
|
||||
searchable={config.mode === "combobox" ? true : config.searchable}
|
||||
|
|
@ -1023,7 +1069,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<RadioSelect
|
||||
options={options}
|
||||
value={typeof value === "string" ? value : value?.[0]}
|
||||
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
|
||||
onChange={(v) => handleChangeWithAutoFill(v)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
|
@ -1034,7 +1080,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<CheckSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
|
|
@ -1045,7 +1091,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<TagSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
|
|
@ -1056,7 +1102,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<TagboxSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
placeholder={config.placeholder || "선택하세요"}
|
||||
maxSelect={config.maxSelect}
|
||||
|
|
@ -1069,7 +1115,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<ToggleSelect
|
||||
options={options}
|
||||
value={typeof value === "string" ? value : value?.[0]}
|
||||
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
|
||||
onChange={(v) => handleChangeWithAutoFill(v)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
|
@ -1079,7 +1125,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<SwapSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
|
|
@ -1090,7 +1136,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
return (
|
||||
<DropdownSelect
|
||||
options={options}
|
||||
value={value}
|
||||
value={resolvedValue}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
disabled={isDisabled}
|
||||
style={heightStyle}
|
||||
|
|
@ -1103,17 +1149,19 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
const componentWidth = size?.width || style?.width;
|
||||
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 labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
||||
const labelGapValue = style?.labelGap || "8px";
|
||||
|
||||
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
|
||||
// 커스텀 스타일 감지
|
||||
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
|
||||
const hasCustomBackground = !!style?.backgroundColor;
|
||||
const hasCustomRadius = !!style?.borderRadius;
|
||||
|
||||
// 텍스트 스타일 오버라이드 (CSS 상속)
|
||||
const customTextStyle: React.CSSProperties = {};
|
||||
if (style?.color) customTextStyle.color = style.color;
|
||||
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"];
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -1131,38 +1231,8 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
||||
{showLabel && (
|
||||
<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>
|
||||
{labelElement}
|
||||
{selectContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,5 +22,9 @@ export type { PendingConfirmState } from "./usePopAction";
|
|||
// 연결 해석기
|
||||
export { useConnectionResolver } from "./useConnectionResolver";
|
||||
|
||||
// 장바구니 동기화 훅
|
||||
export { useCartSync } from "./useCartSync";
|
||||
export type { UseCartSyncReturn } from "./useCartSync";
|
||||
|
||||
// SQL 빌더 유틸 (고급 사용 시)
|
||||
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -356,9 +356,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 1. componentType이 "select-basic" 또는 "v2-select"인 경우
|
||||
// 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등)
|
||||
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 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 (
|
||||
(inputType === "category" || webType === "category") &&
|
||||
|
|
@ -545,10 +546,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
let currentValue;
|
||||
if (componentType === "modal-repeater-table" ||
|
||||
componentType === "repeat-screen-modal" ||
|
||||
componentType === "selected-items-detail-input" ||
|
||||
componentType === "v2-repeater") {
|
||||
componentType === "selected-items-detail-input") {
|
||||
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
|
||||
currentValue = props.groupedData || formData?.[fieldName] || [];
|
||||
} else if (componentType === "v2-repeater") {
|
||||
// V2Repeater는 자체 데이터 관리 (groupedData는 메인 테이블 레코드이므로 사용하지 않음)
|
||||
currentValue = formData?.[fieldName] || [];
|
||||
} else {
|
||||
currentValue = formData?.[fieldName] || "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
|
||||
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
||||
const groupByColumn = rawConfig.groupByColumn;
|
||||
const groupBySourceColumn = rawConfig.groupBySourceColumn || rawConfig.groupByColumn;
|
||||
const targetTable = rawConfig.targetTable;
|
||||
|
||||
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
|
||||
|
|
@ -86,8 +87,8 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
||||
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
||||
|
||||
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
||||
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
||||
// 🆕 그룹 키 값: groupBySourceColumn(formData 키)과 groupByColumn(DB 컬럼)을 분리
|
||||
const groupKeyValue = groupBySourceColumn ? formData?.[groupBySourceColumn] : null;
|
||||
|
||||
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
|
|
|||
|
|
@ -568,6 +568,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
if (hasParentMapping) {
|
||||
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에서)
|
||||
const parentKeys: Record<string, any> = {};
|
||||
|
||||
|
|
@ -581,11 +587,19 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
}
|
||||
|
||||
componentConfig.parentDataMapping.forEach((mapping) => {
|
||||
// 1차: formData(sourceData)에서 찾기
|
||||
let value = getFieldValue(sourceData, mapping.sourceField);
|
||||
let value: any;
|
||||
|
||||
// 수정 모드: 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) {
|
||||
const registryData = dataRegistry[mapping.sourceTable];
|
||||
if (registryData && registryData.length > 0) {
|
||||
|
|
@ -593,6 +607,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
value = registryItem[mapping.sourceField];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
parentKeys[mapping.targetField] = value;
|
||||
|
|
@ -646,15 +661,6 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
const additionalFields = componentConfig.additionalFields || [];
|
||||
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] 수정 모드 감지:", {
|
||||
urlEditMode,
|
||||
dataHasDbId,
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ interface ItemSearchModalProps {
|
|||
onClose: () => void;
|
||||
onSelect: (items: ItemInfo[]) => void;
|
||||
companyCode?: string;
|
||||
existingItemIds?: Set<string>;
|
||||
}
|
||||
|
||||
function ItemSearchModal({
|
||||
|
|
@ -93,6 +94,7 @@ function ItemSearchModal({
|
|||
onClose,
|
||||
onSelect,
|
||||
companyCode,
|
||||
existingItemIds,
|
||||
}: ItemSearchModalProps) {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [items, setItems] = useState<ItemInfo[]>([]);
|
||||
|
|
@ -182,7 +184,7 @@ function ItemSearchModal({
|
|||
</div>
|
||||
) : (
|
||||
<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>
|
||||
<th className="w-8 px-2 py-2 text-center">
|
||||
<Checkbox
|
||||
|
|
@ -200,10 +202,13 @@ function ItemSearchModal({
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
{items.map((item) => {
|
||||
const alreadyAdded = existingItemIds?.has(item.id) || false;
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
if (alreadyAdded) return;
|
||||
setSelectedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id);
|
||||
|
|
@ -212,14 +217,19 @@ function ItemSearchModal({
|
|||
});
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer border-t transition-colors",
|
||||
selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent",
|
||||
"border-t transition-colors",
|
||||
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()}>
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.id)}
|
||||
disabled={alreadyAdded}
|
||||
onCheckedChange={(checked) => {
|
||||
if (alreadyAdded) return;
|
||||
setSelectedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(item.id);
|
||||
|
|
@ -231,12 +241,14 @@ function ItemSearchModal({
|
|||
</td>
|
||||
<td className="px-3 py-2 font-mono">
|
||||
{item.item_number}
|
||||
{alreadyAdded && <span className="text-muted-foreground ml-1 text-[10px]">(추가됨)</span>}
|
||||
</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.unit}</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
|
@ -739,37 +751,40 @@ export function BomItemEditorComponent({
|
|||
[originalNotifyChange, markChanged],
|
||||
);
|
||||
|
||||
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
// EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장
|
||||
useEffect(() => {
|
||||
if (isDesignMode || !bomId) return;
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", {
|
||||
bomId,
|
||||
treeDataLength: treeData.length,
|
||||
hasRef: !!handleSaveAllRef.current,
|
||||
});
|
||||
if (treeData.length > 0 && handleSaveAllRef.current) {
|
||||
if (handleSaveAllRef.current) {
|
||||
const savePromise = handleSaveAllRef.current();
|
||||
if (detail?.pendingPromises) {
|
||||
detail.pendingPromises.push(savePromise);
|
||||
console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료");
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeFormSave", handler);
|
||||
console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode });
|
||||
return () => window.removeEventListener("beforeFormSave", handler);
|
||||
}, [isDesignMode, bomId, treeData.length]);
|
||||
|
||||
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
|
||||
}, [isDesignMode, bomId]);
|
||||
|
||||
const handleSaveAll = useCallback(async () => {
|
||||
if (!bomId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// 저장 시점에도 최신 version_id 조회
|
||||
const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
|
||||
// version_id 확보: 없으면 서버에서 자동 초기화
|
||||
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 result: any[] = [];
|
||||
|
|
@ -797,7 +812,7 @@ export function BomItemEditorComponent({
|
|||
: null;
|
||||
|
||||
if (node._isNew) {
|
||||
const payload: Record<string, any> = {
|
||||
const raw: Record<string, any> = {
|
||||
...node.data,
|
||||
[fkColumn]: bomId,
|
||||
[parentKeyColumn]: realParentId,
|
||||
|
|
@ -806,10 +821,16 @@ export function BomItemEditorComponent({
|
|||
company_code: companyCode || undefined,
|
||||
version_id: saveVersionId || undefined,
|
||||
};
|
||||
delete payload.id;
|
||||
delete payload.tempId;
|
||||
delete payload._isNew;
|
||||
delete payload._isDeleted;
|
||||
// bom_detail에 유효한 필드만 남기기 (item_info 조인 필드 제거)
|
||||
const payload: Record<string, any> = {};
|
||||
const validKeys = new Set([
|
||||
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(
|
||||
`/table-management/tables/${mainTableName}/add`,
|
||||
|
|
@ -820,17 +841,14 @@ export function BomItemEditorComponent({
|
|||
savedCount++;
|
||||
} else if (node.id) {
|
||||
const updatedData: Record<string, any> = {
|
||||
...node.data,
|
||||
id: node.id,
|
||||
[fkColumn]: bomId,
|
||||
[parentKeyColumn]: realParentId,
|
||||
seq_no: String(seqNo),
|
||||
level: String(level),
|
||||
};
|
||||
delete updatedData.tempId;
|
||||
delete updatedData._isNew;
|
||||
delete updatedData._isDeleted;
|
||||
Object.keys(updatedData).forEach((k) => {
|
||||
if (k.startsWith(`${sourceFk}_`)) delete updatedData[k];
|
||||
["quantity", "unit", "loss_rate", "remark", "process_type", "base_qty", "revision", "child_item_id", "version_id", "company_code"].forEach((k) => {
|
||||
if (node.data[k] !== undefined) updatedData[k] = node.data[k];
|
||||
});
|
||||
|
||||
await apiClient.put(
|
||||
|
|
@ -919,6 +937,20 @@ export function BomItemEditorComponent({
|
|||
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(() => {
|
||||
setAddTargetParentId(null);
|
||||
|
|
@ -1338,6 +1370,7 @@ export function BomItemEditorComponent({
|
|||
onClose={() => setItemSearchOpen(false)}
|
||||
onSelect={handleItemSelect}
|
||||
companyCode={companyCode}
|
||||
existingItemIds={existingItemIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
|
|
@ -35,6 +42,20 @@ export function BomDetailEditModal({
|
|||
}: BomDetailEditModalProps) {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
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(() => {
|
||||
if (node && open) {
|
||||
|
|
@ -47,9 +68,7 @@ export function BomDetailEditModal({
|
|||
} else {
|
||||
setFormData({
|
||||
quantity: node.quantity || "",
|
||||
unit: node.unit || node.detail_unit || "",
|
||||
process_type: node.process_type || "",
|
||||
base_qty: node.base_qty || "",
|
||||
loss_rate: node.loss_rate || "",
|
||||
remark: node.remark || "",
|
||||
});
|
||||
|
|
@ -67,11 +86,15 @@ export function BomDetailEditModal({
|
|||
try {
|
||||
const targetTable = isRootNode ? "bom" : tableName;
|
||||
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?.();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("[BomDetailEdit] 저장 실패:", error);
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -126,11 +149,19 @@ export function BomDetailEditModal({
|
|||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">단위</Label>
|
||||
{isRootNode ? (
|
||||
<Input
|
||||
value={formData.unit}
|
||||
onChange={(e) => handleChange("unit", e.target.value)}
|
||||
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>
|
||||
|
||||
|
|
@ -139,12 +170,28 @@ export function BomDetailEditModal({
|
|||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<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
|
||||
value={formData.process_type}
|
||||
onChange={(e) => handleChange("process_type", e.target.value)}
|
||||
placeholder="예: 조립공정"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">로스율 (%)</Label>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
History,
|
||||
GitBranch,
|
||||
Check,
|
||||
FileSpreadsheet,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { BomDetailEditModal } from "./BomDetailEditModal";
|
||||
import { BomHistoryModal } from "./BomHistoryModal";
|
||||
import { BomVersionModal } from "./BomVersionModal";
|
||||
import { BomExcelUploadModal } from "./BomExcelUploadModal";
|
||||
|
||||
interface BomTreeNode {
|
||||
id: string;
|
||||
|
|
@ -77,6 +79,7 @@ export function BomTreeComponent({
|
|||
const [editTargetNode, setEditTargetNode] = useState<BomTreeNode | null>(null);
|
||||
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||
const [versionModalOpen, setVersionModalOpen] = useState(false);
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
||||
|
||||
const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => {
|
||||
|
|
@ -138,6 +141,23 @@ export function BomTreeComponent({
|
|||
const showHistory = features.showHistory !== 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레벨 루트 노드 생성
|
||||
|
|
@ -168,7 +188,18 @@ export function BomTreeComponent({
|
|||
setLoading(true);
|
||||
try {
|
||||
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) {
|
||||
searchFilter.version_id = versionId;
|
||||
}
|
||||
|
|
@ -263,6 +294,7 @@ export function BomTreeComponent({
|
|||
item_name: raw.item_name || "",
|
||||
item_code: raw.item_number || raw.item_code || "",
|
||||
item_type: raw.item_type || raw.division || "",
|
||||
unit: raw.unit || raw.item_unit || "",
|
||||
} as BomHeaderInfo;
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -348,6 +380,18 @@ export function BomTreeComponent({
|
|||
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)보다 반드시 먼저 실행
|
||||
window.addEventListener("openEditModal", handler, true);
|
||||
|
|
@ -461,6 +505,11 @@ export function BomTreeComponent({
|
|||
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") {
|
||||
return (
|
||||
<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") {
|
||||
const num = Number(value);
|
||||
if (!num) return <span className="text-gray-300">-</span>;
|
||||
|
|
@ -786,6 +840,15 @@ export function BomTreeComponent({
|
|||
버전
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExcelUploadOpen(true)}
|
||||
className="h-6 gap-1 px-2 text-[10px]"
|
||||
>
|
||||
<FileSpreadsheet className="h-3 w-3" />
|
||||
엑셀
|
||||
</Button>
|
||||
<div className="mx-1 h-4 w-px bg-gray-200" />
|
||||
<div className="flex overflow-hidden rounded-md border">
|
||||
<button
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [actionId, setActionId] = useState<string | null>(null);
|
||||
const [newVersionName, setNewVersionName] = useState("");
|
||||
const [showNewInput, setShowNewInput] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && bomId) loadVersions();
|
||||
|
|
@ -63,11 +65,26 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
|
|||
|
||||
const handleCreateVersion = async () => {
|
||||
if (!bomId) return;
|
||||
const trimmed = newVersionName.trim();
|
||||
if (!trimmed) {
|
||||
alert("버전명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable });
|
||||
if (res.data?.success) loadVersions();
|
||||
} catch (error) {
|
||||
const res = await apiClient.post(`/bom/${bomId}/versions`, {
|
||||
tableName, detailTable, versionName: trimmed,
|
||||
});
|
||||
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);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
|
|
@ -230,15 +247,46 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
|
|||
)}
|
||||
</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
|
||||
onClick={handleCreateVersion}
|
||||
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"
|
||||
>
|
||||
{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
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ export function ProcessWorkStandardComponent({
|
|||
items,
|
||||
routings,
|
||||
workItems,
|
||||
selectedWorkItemDetails,
|
||||
selectedWorkItemId,
|
||||
selectedWorkItemIdByPhase,
|
||||
selectedDetailsByPhase,
|
||||
selection,
|
||||
loading,
|
||||
fetchItems,
|
||||
|
|
@ -105,8 +105,8 @@ export function ProcessWorkStandardComponent({
|
|||
);
|
||||
|
||||
const handleSelectWorkItem = useCallback(
|
||||
(workItemId: string) => {
|
||||
fetchWorkItemDetails(workItemId);
|
||||
(workItemId: string, phaseKey: string) => {
|
||||
fetchWorkItemDetails(workItemId, phaseKey);
|
||||
},
|
||||
[fetchWorkItemDetails]
|
||||
);
|
||||
|
|
@ -191,8 +191,8 @@ export function ProcessWorkStandardComponent({
|
|||
key={phase.key}
|
||||
phase={phase}
|
||||
items={workItemsByPhase[phase.key] || []}
|
||||
selectedWorkItemId={selectedWorkItemId}
|
||||
selectedWorkItemDetails={selectedWorkItemDetails}
|
||||
selectedWorkItemId={selectedWorkItemIdByPhase[phase.key] || null}
|
||||
selectedWorkItemDetails={selectedDetailsByPhase[phase.key] || []}
|
||||
detailTypes={config.detailTypes}
|
||||
readonly={config.readonly}
|
||||
onSelectWorkItem={handleSelectWorkItem}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -61,11 +61,24 @@ export function WorkItemAddModal({
|
|||
detailTypes,
|
||||
editItem,
|
||||
}: WorkItemAddModalProps) {
|
||||
const [title, setTitle] = useState(editItem?.title || "");
|
||||
const [isRequired, setIsRequired] = useState(editItem?.is_required || "Y");
|
||||
const [description, setDescription] = useState(editItem?.description || "");
|
||||
const [title, setTitle] = useState("");
|
||||
const [isRequired, setIsRequired] = useState("Y");
|
||||
const [description, setDescription] = useState("");
|
||||
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 = () => {
|
||||
setTitle("");
|
||||
setIsRequired("Y");
|
||||
|
|
|
|||
|
|
@ -20,13 +20,13 @@ interface WorkPhaseSectionProps {
|
|||
selectedWorkItemDetails: WorkItemDetail[];
|
||||
detailTypes: DetailTypeDefinition[];
|
||||
readonly?: boolean;
|
||||
onSelectWorkItem: (workItemId: string) => void;
|
||||
onSelectWorkItem: (workItemId: string, phaseKey: string) => void;
|
||||
onAddWorkItem: (phase: string) => void;
|
||||
onEditWorkItem: (item: WorkItem) => void;
|
||||
onDeleteWorkItem: (id: string) => void;
|
||||
onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>) => void;
|
||||
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
|
||||
onDeleteDetail: (id: string) => void;
|
||||
onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>, phaseKey: string) => void;
|
||||
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>, phaseKey: string) => void;
|
||||
onDeleteDetail: (id: string, phaseKey: string) => void;
|
||||
}
|
||||
|
||||
export function WorkPhaseSection({
|
||||
|
|
@ -45,9 +45,6 @@ export function WorkPhaseSection({
|
|||
onDeleteDetail,
|
||||
}: WorkPhaseSectionProps) {
|
||||
const selectedItem = items.find((i) => i.id === selectedWorkItemId) || null;
|
||||
const isThisSectionSelected = items.some(
|
||||
(i) => i.id === selectedWorkItemId
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
|
|
@ -94,7 +91,7 @@ export function WorkPhaseSection({
|
|||
item={item}
|
||||
isSelected={selectedWorkItemId === item.id}
|
||||
readonly={readonly}
|
||||
onClick={() => onSelectWorkItem(item.id)}
|
||||
onClick={() => onSelectWorkItem(item.id, phase.key)}
|
||||
onEdit={() => onEditWorkItem(item)}
|
||||
onDelete={() => onDeleteWorkItem(item.id)}
|
||||
/>
|
||||
|
|
@ -106,15 +103,15 @@ export function WorkPhaseSection({
|
|||
{/* 우측: 상세 리스트 */}
|
||||
<div className="flex-1">
|
||||
<WorkItemDetailList
|
||||
workItem={isThisSectionSelected ? selectedItem : null}
|
||||
details={isThisSectionSelected ? selectedWorkItemDetails : []}
|
||||
workItem={selectedItem}
|
||||
details={selectedWorkItemDetails}
|
||||
detailTypes={detailTypes}
|
||||
readonly={readonly}
|
||||
onCreateDetail={(data) =>
|
||||
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data)
|
||||
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data, phase.key)
|
||||
}
|
||||
onUpdateDetail={onUpdateDetail}
|
||||
onDeleteDetail={onDeleteDetail}
|
||||
onUpdateDetail={(id, data) => onUpdateDetail(id, data, phase.key)}
|
||||
onDeleteDetail={(id) => onDeleteDetail(id, phase.key)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ export const defaultConfig: ProcessWorkStandardConfig = {
|
|||
{ value: "inspect", label: "검사항목" },
|
||||
{ value: "procedure", label: "작업절차" },
|
||||
{ value: "input", label: "직접입력" },
|
||||
{ value: "info", label: "정보조회" },
|
||||
],
|
||||
splitRatio: 30,
|
||||
leftPanelTitle: "품목 및 공정 선택",
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
const [items, setItems] = useState<ItemData[]>([]);
|
||||
const [routings, setRoutings] = useState<RoutingVersion[]>([]);
|
||||
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
|
||||
const [selectedWorkItemDetails, setSelectedWorkItemDetails] = useState<WorkItemDetail[]>([]);
|
||||
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null);
|
||||
// 섹션(phase)별 독립적인 선택 상태 관리
|
||||
const [selectedWorkItemIdByPhase, setSelectedWorkItemIdByPhase] = useState<Record<string, string | null>>({});
|
||||
const [selectedDetailsByPhase, setSelectedDetailsByPhase] = useState<Record<string, WorkItemDetail[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
|
|
@ -101,15 +102,15 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 작업 항목 상세 조회
|
||||
const fetchWorkItemDetails = useCallback(async (workItemId: string) => {
|
||||
// 작업 항목 상세 조회 (phase별 독립 저장)
|
||||
const fetchWorkItemDetails = useCallback(async (workItemId: string, phaseKey: string) => {
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
`${API_BASE}/work-items/${workItemId}/details`
|
||||
);
|
||||
if (res.data?.success) {
|
||||
setSelectedWorkItemDetails(res.data.data);
|
||||
setSelectedWorkItemId(workItemId);
|
||||
setSelectedDetailsByPhase(prev => ({ ...prev, [phaseKey]: res.data.data }));
|
||||
setSelectedWorkItemIdByPhase(prev => ({ ...prev, [phaseKey]: workItemId }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("상세 조회 실패", err);
|
||||
|
|
@ -129,8 +130,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
processName: null,
|
||||
}));
|
||||
setWorkItems([]);
|
||||
setSelectedWorkItemDetails([]);
|
||||
setSelectedWorkItemId(null);
|
||||
setSelectedDetailsByPhase({});
|
||||
setSelectedWorkItemIdByPhase({});
|
||||
await fetchRoutings(itemCode);
|
||||
},
|
||||
[fetchRoutings]
|
||||
|
|
@ -151,8 +152,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
routingDetailId,
|
||||
processName,
|
||||
}));
|
||||
setSelectedWorkItemDetails([]);
|
||||
setSelectedWorkItemId(null);
|
||||
setSelectedDetailsByPhase({});
|
||||
setSelectedWorkItemIdByPhase({});
|
||||
await fetchWorkItems(routingDetailId);
|
||||
},
|
||||
[fetchWorkItems]
|
||||
|
|
@ -233,28 +234,43 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
const res = await apiClient.delete(`${API_BASE}/work-items/${id}`);
|
||||
if (res.data?.success && selection.routingDetailId) {
|
||||
await fetchWorkItems(selection.routingDetailId);
|
||||
if (selectedWorkItemId === id) {
|
||||
setSelectedWorkItemDetails([]);
|
||||
setSelectedWorkItemId(null);
|
||||
// 삭제된 항목이 선택되어 있던 phase의 선택 상태 초기화
|
||||
setSelectedWorkItemIdByPhase(prev => {
|
||||
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) {
|
||||
console.error("작업 항목 삭제 실패", err);
|
||||
}
|
||||
},
|
||||
[selection.routingDetailId, selectedWorkItemId, fetchWorkItems]
|
||||
[selection.routingDetailId, selectedWorkItemIdByPhase, fetchWorkItems]
|
||||
);
|
||||
|
||||
// 상세 추가
|
||||
const createDetail = useCallback(
|
||||
async (workItemId: string, data: Partial<WorkItemDetail>) => {
|
||||
async (workItemId: string, data: Partial<WorkItemDetail>, phaseKey: string) => {
|
||||
try {
|
||||
const res = await apiClient.post(`${API_BASE}/work-item-details`, {
|
||||
work_item_id: workItemId,
|
||||
...data,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
await fetchWorkItemDetails(workItemId);
|
||||
await fetchWorkItemDetails(workItemId, phaseKey);
|
||||
if (selection.routingDetailId) {
|
||||
await fetchWorkItems(selection.routingDetailId);
|
||||
}
|
||||
|
|
@ -268,32 +284,36 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
|
||||
// 상세 수정
|
||||
const updateDetail = useCallback(
|
||||
async (id: string, data: Partial<WorkItemDetail>) => {
|
||||
async (id: string, data: Partial<WorkItemDetail>, phaseKey: string) => {
|
||||
try {
|
||||
const res = await apiClient.put(
|
||||
`${API_BASE}/work-item-details/${id}`,
|
||||
data
|
||||
);
|
||||
if (res.data?.success && selectedWorkItemId) {
|
||||
await fetchWorkItemDetails(selectedWorkItemId);
|
||||
if (res.data?.success) {
|
||||
const workItemId = selectedWorkItemIdByPhase[phaseKey];
|
||||
if (workItemId) {
|
||||
await fetchWorkItemDetails(workItemId, phaseKey);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("상세 수정 실패", err);
|
||||
}
|
||||
},
|
||||
[selectedWorkItemId, fetchWorkItemDetails]
|
||||
[selectedWorkItemIdByPhase, fetchWorkItemDetails]
|
||||
);
|
||||
|
||||
// 상세 삭제
|
||||
const deleteDetail = useCallback(
|
||||
async (id: string) => {
|
||||
async (id: string, phaseKey: string) => {
|
||||
try {
|
||||
const res = await apiClient.delete(
|
||||
`${API_BASE}/work-item-details/${id}`
|
||||
);
|
||||
if (res.data?.success) {
|
||||
if (selectedWorkItemId) {
|
||||
await fetchWorkItemDetails(selectedWorkItemId);
|
||||
const workItemId = selectedWorkItemIdByPhase[phaseKey];
|
||||
if (workItemId) {
|
||||
await fetchWorkItemDetails(workItemId, phaseKey);
|
||||
}
|
||||
if (selection.routingDetailId) {
|
||||
await fetchWorkItems(selection.routingDetailId);
|
||||
|
|
@ -304,7 +324,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
}
|
||||
},
|
||||
[
|
||||
selectedWorkItemId,
|
||||
selectedWorkItemIdByPhase,
|
||||
selection.routingDetailId,
|
||||
fetchWorkItemDetails,
|
||||
fetchWorkItems,
|
||||
|
|
@ -315,8 +335,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
items,
|
||||
routings,
|
||||
workItems,
|
||||
selectedWorkItemDetails,
|
||||
selectedWorkItemId,
|
||||
selectedWorkItemIdByPhase,
|
||||
selectedDetailsByPhase,
|
||||
selection,
|
||||
loading,
|
||||
saving,
|
||||
|
|
@ -325,7 +345,6 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
|||
selectProcess,
|
||||
fetchWorkItems,
|
||||
fetchWorkItemDetails,
|
||||
setSelectedWorkItemId,
|
||||
createWorkItem,
|
||||
updateWorkItem,
|
||||
deleteWorkItem,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
Trash2,
|
||||
Settings,
|
||||
Move,
|
||||
FileSpreadsheet,
|
||||
} from "lucide-react";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
|
|
@ -43,6 +44,7 @@ import { useSplitPanel } from "./SplitPanelContext";
|
|||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { PanelInlineComponent } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal";
|
||||
|
||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
|
|
@ -500,6 +502,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
|
||||
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
|
||||
const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false);
|
||||
|
||||
// 수정 모달 상태
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
|
@ -3010,6 +3013,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
<CardTitle className="text-base font-semibold">
|
||||
{componentConfig.leftPanel?.title || "좌측 패널"}
|
||||
</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 && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleAddClick("left")}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
|
|
@ -3017,6 +3027,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{componentConfig.leftPanel?.showSearch && (
|
||||
<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) {
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
|
|
@ -3385,6 +3400,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
{col.label}
|
||||
</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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
|
|
@ -3399,7 +3418,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
<tr
|
||||
key={itemId}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||
className={`group hover:bg-accent cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-primary/10" : ""
|
||||
}`}
|
||||
>
|
||||
|
|
@ -3417,6 +3436,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
|
@ -3429,6 +3476,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
|
||||
// 🔧 일반 테이블 렌더링 (그룹화 없음)
|
||||
const hasLeftTableActions = !isDesignMode && (
|
||||
(componentConfig.leftPanel?.showEdit !== false) ||
|
||||
(componentConfig.leftPanel?.showDelete !== false)
|
||||
);
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
|
|
@ -3447,6 +3498,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
{col.label}
|
||||
</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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
|
|
@ -3461,7 +3516,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
<tr
|
||||
key={itemId}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||
className={`group hover:bg-accent cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-primary/10" : ""
|
||||
}`}
|
||||
>
|
||||
|
|
@ -3479,6 +3534,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
|
@ -4998,6 +5081,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{(componentConfig.leftPanel as any)?.showBomExcelUpload && (
|
||||
<BomExcelUploadModal
|
||||
open={bomExcelUploadOpen}
|
||||
onOpenChange={setBomExcelUploadOpen}
|
||||
onSuccess={() => {
|
||||
loadLeftData();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2243,6 +2243,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글)
|
||||
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 현재 편집 중인 셀을 클릭한 경우 포커스 이동 방지 (select 드롭다운 등이 닫히는 것 방지)
|
||||
if (editingCell?.rowIndex === rowIndex && editingCell?.colIndex === colIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedCell({ rowIndex, colIndex });
|
||||
tableContainerRef.current?.focus();
|
||||
|
||||
|
|
@ -5462,23 +5468,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</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) && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import React, { useCallback, useState, useEffect, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -24,8 +24,10 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||
import { DataFlowAPI } from "@/lib/api/dataflow";
|
||||
import { usePopAction } from "@/hooks/pop/usePopAction";
|
||||
import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
|
||||
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||
import {
|
||||
Save,
|
||||
Trash2,
|
||||
|
|
@ -44,6 +46,8 @@ import {
|
|||
Copy,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ShoppingCart,
|
||||
ShoppingBag,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -113,18 +117,30 @@ export type ButtonPreset =
|
|||
| "logout"
|
||||
| "menu"
|
||||
| "modal-open"
|
||||
| "cart"
|
||||
| "custom";
|
||||
|
||||
/** row_data 저장 모드 */
|
||||
export type RowDataMode = "all" | "selected";
|
||||
|
||||
/** 장바구니 버튼 전용 설정 */
|
||||
export interface CartButtonConfig {
|
||||
cartScreenId?: string;
|
||||
rowDataMode?: RowDataMode;
|
||||
selectedColumns?: string[];
|
||||
}
|
||||
|
||||
/** pop-button 전체 설정 */
|
||||
export interface PopButtonConfig {
|
||||
label: string;
|
||||
variant: ButtonVariant;
|
||||
icon?: string; // Lucide 아이콘 이름
|
||||
icon?: string;
|
||||
iconOnly?: boolean;
|
||||
preset: ButtonPreset;
|
||||
confirm?: ConfirmConfig;
|
||||
action: ButtonMainAction;
|
||||
followUpActions?: FollowUpAction[];
|
||||
cart?: CartButtonConfig;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
@ -163,6 +179,7 @@ const PRESET_LABELS: Record<ButtonPreset, string> = {
|
|||
logout: "로그아웃",
|
||||
menu: "메뉴 (드롭다운)",
|
||||
"modal-open": "모달 열기",
|
||||
cart: "장바구니 저장",
|
||||
custom: "직접 설정",
|
||||
};
|
||||
|
||||
|
|
@ -201,6 +218,8 @@ const ICON_OPTIONS: { value: string; label: string }[] = [
|
|||
{ value: "Copy", label: "복사 (Copy)" },
|
||||
{ value: "Settings", label: "설정 (Settings)" },
|
||||
{ value: "ChevronDown", label: "아래 화살표 (ChevronDown)" },
|
||||
{ value: "ShoppingCart", label: "장바구니 (ShoppingCart)" },
|
||||
{ value: "ShoppingBag", label: "장바구니 담김 (ShoppingBag)" },
|
||||
];
|
||||
|
||||
/** 프리셋별 기본 설정 */
|
||||
|
|
@ -244,6 +263,13 @@ const PRESET_DEFAULTS: Record<ButtonPreset, Partial<PopButtonConfig>> = {
|
|||
confirm: { enabled: false },
|
||||
action: { type: "modal", modalMode: "fullscreen" },
|
||||
},
|
||||
cart: {
|
||||
label: "장바구니 저장",
|
||||
variant: "default",
|
||||
icon: "ShoppingCart",
|
||||
confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" },
|
||||
action: { type: "event" },
|
||||
},
|
||||
custom: {
|
||||
label: "버튼",
|
||||
variant: "default",
|
||||
|
|
@ -279,10 +305,42 @@ function SectionDivider({ label }: { label: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** 장바구니 데이터 매핑 행 (읽기 전용) */
|
||||
function CartMappingRow({
|
||||
source,
|
||||
target,
|
||||
desc,
|
||||
auto,
|
||||
}: {
|
||||
source: string;
|
||||
target: string;
|
||||
desc?: string;
|
||||
auto?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-1 py-0.5">
|
||||
<span className={cn("min-w-0 flex-1 text-[10px]", auto ? "text-muted-foreground" : "text-foreground")}>
|
||||
{source}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">→</span>
|
||||
<div className="shrink-0 text-right">
|
||||
<code className="rounded bg-muted px-1 font-mono text-[10px] text-foreground">
|
||||
{target}
|
||||
</code>
|
||||
{desc && (
|
||||
<p className="mt-0.5 text-[9px] text-muted-foreground leading-tight">{desc}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */
|
||||
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||
Save, Trash2, LogOut, Menu, ExternalLink, Plus, Check, X,
|
||||
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
|
||||
ShoppingCart,
|
||||
ShoppingBag,
|
||||
};
|
||||
|
||||
/** Lucide 아이콘 동적 렌더링 */
|
||||
|
|
@ -309,6 +367,7 @@ interface PopButtonComponentProps {
|
|||
label?: string;
|
||||
isDesignMode?: boolean;
|
||||
screenId?: string;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
export function PopButtonComponent({
|
||||
|
|
@ -316,8 +375,8 @@ export function PopButtonComponent({
|
|||
label,
|
||||
isDesignMode,
|
||||
screenId,
|
||||
componentId,
|
||||
}: PopButtonComponentProps) {
|
||||
// usePopAction 훅으로 액션 실행 통합
|
||||
const {
|
||||
execute,
|
||||
isLoading,
|
||||
|
|
@ -326,23 +385,127 @@ export function PopButtonComponent({
|
|||
cancelConfirm,
|
||||
} = usePopAction(screenId || "");
|
||||
|
||||
// 확인 메시지 결정
|
||||
const { subscribe, publish } = usePopEvent(screenId || "default");
|
||||
|
||||
// 장바구니 모드 상태
|
||||
const isCartMode = config?.preset === "cart";
|
||||
const [cartCount, setCartCount] = useState(0);
|
||||
const [cartIsDirty, setCartIsDirty] = useState(false);
|
||||
const [cartSaving, setCartSaving] = useState(false);
|
||||
const [showCartConfirm, setShowCartConfirm] = useState(false);
|
||||
|
||||
// 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달)
|
||||
useEffect(() => {
|
||||
if (!isCartMode || !componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__cart_updated`,
|
||||
(payload: unknown) => {
|
||||
const data = payload as { value?: { count?: number; isDirty?: boolean } } | undefined;
|
||||
const inner = data?.value;
|
||||
if (inner?.count !== undefined) setCartCount(inner.count);
|
||||
if (inner?.isDirty !== undefined) setCartIsDirty(inner.isDirty);
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [isCartMode, componentId, subscribe]);
|
||||
|
||||
// 저장 완료 수신 (카드 목록에서 saveToDb 완료 후 전달)
|
||||
const cartScreenIdRef = React.useRef(config?.cart?.cartScreenId);
|
||||
cartScreenIdRef.current = config?.cart?.cartScreenId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCartMode || !componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__cart_save_completed`,
|
||||
(payload: unknown) => {
|
||||
const data = payload as { value?: { success?: boolean } } | undefined;
|
||||
setCartSaving(false);
|
||||
if (data?.value?.success) {
|
||||
setCartIsDirty(false);
|
||||
const targetScreenId = cartScreenIdRef.current;
|
||||
if (targetScreenId) {
|
||||
const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim();
|
||||
window.location.href = `/pop/screens/${cleanId}`;
|
||||
} else {
|
||||
toast.success("장바구니가 저장되었습니다.");
|
||||
}
|
||||
} else {
|
||||
toast.error("장바구니 저장에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [isCartMode, componentId, subscribe]);
|
||||
|
||||
const getConfirmMessage = useCallback((): string => {
|
||||
if (pendingConfirm?.confirm?.message) return pendingConfirm.confirm.message;
|
||||
if (config?.confirm?.message) return config.confirm.message;
|
||||
return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"];
|
||||
}, [pendingConfirm?.confirm?.message, config?.confirm?.message, config?.action?.type]);
|
||||
|
||||
// 장바구니 저장 트리거 (연결 미설정 시 10초 타임아웃으로 복구)
|
||||
const cartSaveTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCartSave = useCallback(() => {
|
||||
if (!componentId) return;
|
||||
setCartSaving(true);
|
||||
const selectedCols =
|
||||
config?.cart?.rowDataMode === "selected" ? config.cart.selectedColumns : undefined;
|
||||
publish(`__comp_output__${componentId}__cart_save_trigger`, {
|
||||
selectedColumns: selectedCols,
|
||||
});
|
||||
|
||||
if (cartSaveTimeoutRef.current) clearTimeout(cartSaveTimeoutRef.current);
|
||||
cartSaveTimeoutRef.current = setTimeout(() => {
|
||||
setCartSaving((prev) => {
|
||||
if (prev) {
|
||||
toast.error("장바구니 저장 응답이 없습니다. 연결 설정을 확인하세요.");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, 10_000);
|
||||
}, [componentId, publish, config?.cart?.rowDataMode, config?.cart?.selectedColumns]);
|
||||
|
||||
// 저장 완료 시 타임아웃 정리
|
||||
useEffect(() => {
|
||||
if (!cartSaving && cartSaveTimeoutRef.current) {
|
||||
clearTimeout(cartSaveTimeoutRef.current);
|
||||
cartSaveTimeoutRef.current = null;
|
||||
}
|
||||
}, [cartSaving]);
|
||||
|
||||
// 클릭 핸들러
|
||||
const handleClick = useCallback(async () => {
|
||||
// 디자인 모드: 실제 실행 안 함
|
||||
if (isDesignMode) {
|
||||
toast.info(
|
||||
`[디자인 모드] ${ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
|
||||
`[디자인 모드] ${isCartMode ? "장바구니 저장" : ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 장바구니 모드: isDirty 여부에 따라 분기
|
||||
if (isCartMode) {
|
||||
if (cartCount === 0 && !cartIsDirty) {
|
||||
toast.info("장바구니가 비어 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (cartIsDirty) {
|
||||
// 새로 담은 항목이 있음 → 확인 후 저장
|
||||
setShowCartConfirm(true);
|
||||
} else {
|
||||
// 이미 저장된 상태 → 바로 장바구니 화면 이동
|
||||
const targetScreenId = config?.cart?.cartScreenId;
|
||||
if (targetScreenId) {
|
||||
const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim();
|
||||
window.location.href = `/pop/screens/${cleanId}`;
|
||||
} else {
|
||||
toast.info("장바구니 화면이 설정되지 않았습니다.");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const action = config?.action;
|
||||
if (!action) return;
|
||||
|
||||
|
|
@ -350,7 +513,7 @@ export function PopButtonComponent({
|
|||
confirm: config?.confirm,
|
||||
followUpActions: config?.followUpActions,
|
||||
});
|
||||
}, [isDesignMode, config?.action, config?.confirm, config?.followUpActions, execute]);
|
||||
}, [isDesignMode, isCartMode, config, cartCount, cartIsDirty, execute, handleCartSave]);
|
||||
|
||||
// 외형
|
||||
const buttonLabel = config?.label || label || "버튼";
|
||||
|
|
@ -358,30 +521,96 @@ export function PopButtonComponent({
|
|||
const iconName = config?.icon || "";
|
||||
const isIconOnly = config?.iconOnly || false;
|
||||
|
||||
// 장바구니 3상태 아이콘: 빈 장바구니 / 저장 완료 / 변경사항 있음
|
||||
const cartIconName = useMemo(() => {
|
||||
if (!isCartMode) return iconName;
|
||||
if (cartCount === 0 && !cartIsDirty) return "ShoppingCart";
|
||||
if (cartCount > 0 && !cartIsDirty) return "ShoppingBag";
|
||||
return "ShoppingCart";
|
||||
}, [isCartMode, cartCount, cartIsDirty, iconName]);
|
||||
|
||||
// 장바구니 3상태 버튼 색상
|
||||
const cartButtonClass = useMemo(() => {
|
||||
if (!isCartMode) return "";
|
||||
if (cartCount > 0 && !cartIsDirty) {
|
||||
return "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600";
|
||||
}
|
||||
if (cartIsDirty) {
|
||||
return "bg-orange-500 hover:bg-orange-600 text-white border-orange-500 animate-pulse";
|
||||
}
|
||||
return "";
|
||||
}, [isCartMode, cartCount, cartIsDirty]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || cartSaving}
|
||||
className={cn(
|
||||
"transition-transform active:scale-95",
|
||||
isIconOnly && "px-2"
|
||||
isIconOnly && "px-2",
|
||||
cartButtonClass,
|
||||
)}
|
||||
>
|
||||
{iconName && (
|
||||
{(isCartMode ? cartIconName : iconName) && (
|
||||
<DynamicLucideIcon
|
||||
name={iconName}
|
||||
name={isCartMode ? cartIconName : iconName}
|
||||
size={16}
|
||||
className={isIconOnly ? "" : "mr-1.5"}
|
||||
/>
|
||||
)}
|
||||
{!isIconOnly && <span>{buttonLabel}</span>}
|
||||
</Button>
|
||||
|
||||
{/* 장바구니 배지 */}
|
||||
{isCartMode && cartCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-2 -right-2 flex items-center justify-center rounded-full text-[10px] font-bold",
|
||||
cartIsDirty
|
||||
? "bg-orange-500 text-white"
|
||||
: "bg-emerald-600 text-white",
|
||||
)}
|
||||
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
|
||||
>
|
||||
{cartCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 (usePopAction의 pendingConfirm 상태로 제어) */}
|
||||
{/* 장바구니 확인 다이얼로그 */}
|
||||
<AlertDialog open={showCartConfirm} onOpenChange={setShowCartConfirm}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">
|
||||
장바구니 저장
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
{config?.confirm?.message || `${cartCount}개 항목을 장바구니에 저장하시겠습니까?`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
취소
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setShowCartConfirm(false);
|
||||
handleCartSave();
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
저장
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일반 확인 다이얼로그 */}
|
||||
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||
<AlertDialogHeader>
|
||||
|
|
@ -420,14 +649,117 @@ export function PopButtonComponent({
|
|||
interface PopButtonConfigPanelProps {
|
||||
config: PopButtonConfig;
|
||||
onUpdate: (config: PopButtonConfig) => void;
|
||||
allComponents?: { id: string; type: string; config?: Record<string, unknown> }[];
|
||||
connections?: { sourceComponent: string; targetComponent: string; sourceOutput?: string; targetInput?: string }[];
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
export function PopButtonConfigPanel({
|
||||
config,
|
||||
onUpdate,
|
||||
allComponents,
|
||||
connections,
|
||||
componentId,
|
||||
}: PopButtonConfigPanelProps) {
|
||||
const isCustom = config?.preset === "custom";
|
||||
|
||||
// 컬럼 불러오기용 상태
|
||||
const [loadedColumns, setLoadedColumns] = useState<{ name: string; label: string }[]>([]);
|
||||
const [colLoading, setColLoading] = useState(false);
|
||||
const [connectedTableName, setConnectedTableName] = useState<string | null>(null);
|
||||
|
||||
// 연결된 카드 목록의 테이블명 자동 탐색
|
||||
useEffect(() => {
|
||||
if (config?.preset !== "cart" || !componentId || !connections || !allComponents) {
|
||||
setConnectedTableName(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 방법 1: 버튼(source) -> 카드목록(target) cart_save_trigger 연결
|
||||
let cardListId: string | undefined;
|
||||
const outConn = connections.find(
|
||||
(c) =>
|
||||
c.sourceComponent === componentId &&
|
||||
c.sourceOutput === "cart_save_trigger",
|
||||
);
|
||||
if (outConn) {
|
||||
cardListId = outConn.targetComponent;
|
||||
}
|
||||
|
||||
// 방법 2: 카드목록(source) -> 버튼(target) cart_updated 연결 (역방향)
|
||||
if (!cardListId) {
|
||||
const inConn = connections.find(
|
||||
(c) =>
|
||||
c.targetComponent === componentId &&
|
||||
(c.sourceOutput === "cart_updated" || c.sourceOutput === "cart_save_completed"),
|
||||
);
|
||||
if (inConn) {
|
||||
cardListId = inConn.sourceComponent;
|
||||
}
|
||||
}
|
||||
|
||||
// 방법 3: 버튼과 연결된 pop-card-list 타입 컴포넌트 탐색
|
||||
if (!cardListId) {
|
||||
const anyConn = connections.find(
|
||||
(c) =>
|
||||
(c.sourceComponent === componentId || c.targetComponent === componentId),
|
||||
);
|
||||
if (anyConn) {
|
||||
const otherId = anyConn.sourceComponent === componentId
|
||||
? anyConn.targetComponent
|
||||
: anyConn.sourceComponent;
|
||||
const otherComp = allComponents.find((c) => c.id === otherId);
|
||||
if (otherComp?.type === "pop-card-list") {
|
||||
cardListId = otherId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!cardListId) {
|
||||
setConnectedTableName(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const cardList = allComponents.find((c) => c.id === cardListId);
|
||||
const cfg = cardList?.config as Record<string, unknown> | undefined;
|
||||
const dataSource = cfg?.dataSource as Record<string, unknown> | undefined;
|
||||
const tableName = (dataSource?.tableName as string) || (cfg?.tableName as string) || undefined;
|
||||
setConnectedTableName(tableName || null);
|
||||
}, [config?.preset, componentId, connections, allComponents]);
|
||||
|
||||
// 선택 저장 모드 + 연결 테이블명이 있으면 컬럼 자동 로드
|
||||
useEffect(() => {
|
||||
if (config?.cart?.rowDataMode !== "selected" || !connectedTableName) {
|
||||
return;
|
||||
}
|
||||
// 이미 같은 테이블의 컬럼이 로드되어 있으면 스킵
|
||||
if (loadedColumns.length > 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
setColLoading(true);
|
||||
DataFlowAPI.getTableColumns(connectedTableName)
|
||||
.then((cols) => {
|
||||
if (cancelled) return;
|
||||
setLoadedColumns(
|
||||
cols
|
||||
.filter((c: { columnName: string }) =>
|
||||
!["id", "created_at", "updated_at", "created_by", "updated_by"].includes(c.columnName),
|
||||
)
|
||||
.map((c: { columnName: string; displayName?: string }) => ({
|
||||
name: c.columnName,
|
||||
label: c.displayName || c.columnName,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadedColumns([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setColLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [config?.cart?.rowDataMode, connectedTableName, loadedColumns.length]);
|
||||
|
||||
// 프리셋 변경 핸들러
|
||||
const handlePresetChange = (preset: ButtonPreset) => {
|
||||
const defaults = PRESET_DEFAULTS[preset];
|
||||
|
|
@ -554,10 +886,169 @@ export function PopButtonConfigPanel({
|
|||
</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]">
|
||||
카드 목록에서 "담기" 클릭 시 아래와 같이 <code className="rounded bg-muted px-1 font-mono text-foreground">cart_items</code> 테이블에 저장됩니다.
|
||||
</p>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{/* 사용자 입력 데이터 */}
|
||||
<div className="rounded-md border bg-amber-50/50 px-2.5 py-1.5 dark:bg-amber-950/20">
|
||||
<p className="mb-1 text-[10px] font-medium text-amber-700 dark:text-amber-400">사용자 입력</p>
|
||||
<CartMappingRow source="입력한 수량" target="quantity" />
|
||||
<CartMappingRow source="포장 단위" target="package_unit" />
|
||||
<CartMappingRow source="포장 내역 (JSON)" target="package_entries" />
|
||||
<CartMappingRow source="메모" target="memo" />
|
||||
</div>
|
||||
|
||||
{/* 원본 데이터 */}
|
||||
<div className="rounded-md border bg-blue-50/50 px-2.5 py-1.5 dark:bg-blue-950/20">
|
||||
<p className="mb-1 text-[10px] font-medium text-blue-700 dark:text-blue-400">원본 행 데이터</p>
|
||||
|
||||
{/* 저장 모드 선택 */}
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground">저장 모드:</span>
|
||||
<Select
|
||||
value={config?.cart?.rowDataMode || "all"}
|
||||
onValueChange={(v) =>
|
||||
onUpdate({
|
||||
...config,
|
||||
cart: { ...config.cart, rowDataMode: v as RowDataMode },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[100px] text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="text-xs">전체 저장</SelectItem>
|
||||
<SelectItem value="selected" className="text-xs">선택 저장</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config?.cart?.rowDataMode === "selected" ? (
|
||||
<>
|
||||
{/* 선택 저장 모드: 컬럼 목록 관리 */}
|
||||
<div className="space-y-1.5">
|
||||
{connectedTableName ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
연결: <code className="rounded bg-muted px-1 font-mono text-foreground">{connectedTableName}</code>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[9px] text-amber-600 dark:text-amber-400">
|
||||
카드 목록과 연결(cart_save_trigger)하면 컬럼이 자동으로 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{colLoading && (
|
||||
<p className="text-[9px] text-muted-foreground">컬럼 불러오는 중...</p>
|
||||
)}
|
||||
|
||||
{/* 불러온 컬럼 체크박스 */}
|
||||
{loadedColumns.length > 0 && (
|
||||
<div className="max-h-[160px] space-y-0.5 overflow-y-auto rounded border bg-background p-1.5">
|
||||
{loadedColumns.map((col) => {
|
||||
const isChecked = (config?.cart?.selectedColumns || []).includes(col.name);
|
||||
return (
|
||||
<label key={col.name} className="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
const prev = config?.cart?.selectedColumns || [];
|
||||
const next = checked
|
||||
? [...prev, col.name]
|
||||
: prev.filter((c) => c !== col.name);
|
||||
onUpdate({
|
||||
...config,
|
||||
cart: { ...config.cart, selectedColumns: next },
|
||||
});
|
||||
}}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<span className="text-[10px]">{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="text-[9px] text-muted-foreground">({col.name})</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택된 컬럼 요약 */}
|
||||
{(config?.cart?.selectedColumns?.length ?? 0) > 0 ? (
|
||||
<CartMappingRow
|
||||
source={`선택된 ${config!.cart!.selectedColumns!.length}개 컬럼 (JSON)`}
|
||||
target="row_data"
|
||||
desc={config!.cart!.selectedColumns!.join(", ")}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[9px] text-amber-600 dark:text-amber-400">
|
||||
저장할 컬럼을 선택하세요. 미선택 시 전체 저장됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<CartMappingRow source="행 전체 (JSON)" target="row_data" desc="원본 테이블의 모든 컬럼이 JSON으로 저장" />
|
||||
)}
|
||||
|
||||
<CartMappingRow source="행 식별키 (PK)" target="row_key" />
|
||||
<CartMappingRow source="원본 테이블명" target="source_table" />
|
||||
</div>
|
||||
|
||||
{/* 시스템 자동 */}
|
||||
<div className="rounded-md border bg-muted/30 px-2.5 py-1.5">
|
||||
<p className="mb-1 text-[10px] font-medium text-muted-foreground">자동 설정</p>
|
||||
<CartMappingRow source="현재 화면 ID" target="screen_id" auto />
|
||||
<CartMappingRow source='장바구니 타입 ("pop")' target="cart_type" auto />
|
||||
<CartMappingRow source='상태 ("in_cart")' target="status" auto />
|
||||
<CartMappingRow source="회사 코드" target="company_code" auto />
|
||||
<CartMappingRow source="사용자 ID" target="user_id" auto />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-[10px] leading-relaxed">
|
||||
장바구니 목록 화면에서 <code className="rounded bg-muted px-1 font-mono text-foreground">row_data</code>의 JSON을 풀어서
|
||||
최종 대상 테이블로 매핑할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 메인 액션 (cart 프리셋에서는 숨김) */}
|
||||
{config?.preset !== "cart" && (
|
||||
<>
|
||||
<SectionDivider label="메인 액션" />
|
||||
<div className="space-y-2">
|
||||
{/* 액션 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">액션 유형</Label>
|
||||
<Select
|
||||
|
|
@ -584,14 +1075,14 @@ export function PopButtonConfigPanel({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션별 추가 설정 */}
|
||||
<ActionDetailFields
|
||||
action={config?.action}
|
||||
onUpdate={updateAction}
|
||||
disabled={!isCustom}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
<SectionDivider label="확인 메시지" />
|
||||
|
|
@ -980,7 +1471,7 @@ function PopButtonPreviewComponent({
|
|||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-button",
|
||||
name: "버튼",
|
||||
description: "액션 버튼 (저장/삭제/API/모달/이벤트)",
|
||||
description: "액션 버튼 (저장/삭제/API/모달/이벤트/장바구니)",
|
||||
category: "action",
|
||||
icon: "MousePointerClick",
|
||||
component: PopButtonComponent,
|
||||
|
|
@ -993,6 +1484,15 @@ PopComponentRegistry.registerComponent({
|
|||
confirm: { enabled: false },
|
||||
action: { type: "save" },
|
||||
} as PopButtonConfig,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{ key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" },
|
||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "카드 목록에서 DB 저장 완료 시 수신" },
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Delete } from "lucide-react";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Delete, Trash2, Plus, ArrowLeft } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import {
|
||||
PackageUnitModal,
|
||||
PACKAGE_UNITS,
|
||||
type PackageUnit,
|
||||
} from "./PackageUnitModal";
|
||||
import type { CardPackageConfig, PackageEntry } from "../types";
|
||||
|
||||
type InputStep =
|
||||
| "quantity" // 기본: 직접 수량 입력 (포장 OFF)
|
||||
| "package_count" // 포장: 포장 수량 (N개)
|
||||
| "quantity_per_unit" // 포장: 개당 수량 (M EA)
|
||||
| "summary"; // 포장: 결과 확인 + 추가/완료
|
||||
|
||||
interface NumberInputModalProps {
|
||||
open: boolean;
|
||||
|
|
@ -22,7 +30,10 @@ interface NumberInputModalProps {
|
|||
initialPackageUnit?: string;
|
||||
min?: number;
|
||||
maxValue?: number;
|
||||
onConfirm: (value: number, packageUnit?: string) => void;
|
||||
/** @deprecated packageConfig 사용 */
|
||||
showPackageUnit?: boolean;
|
||||
packageConfig?: CardPackageConfig;
|
||||
onConfirm: (value: number, packageUnit?: string, packageEntries?: PackageEntry[]) => void;
|
||||
}
|
||||
|
||||
export function NumberInputModal({
|
||||
|
|
@ -33,51 +44,184 @@ export function NumberInputModal({
|
|||
initialPackageUnit,
|
||||
min = 0,
|
||||
maxValue = 999999,
|
||||
showPackageUnit,
|
||||
packageConfig,
|
||||
onConfirm,
|
||||
}: NumberInputModalProps) {
|
||||
const [displayValue, setDisplayValue] = useState("");
|
||||
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
|
||||
const [step, setStep] = useState<InputStep>("quantity");
|
||||
const [isPackageModalOpen, setIsPackageModalOpen] = useState(false);
|
||||
|
||||
// 포장 2단계 플로우용 상태
|
||||
const [selectedUnit, setSelectedUnit] = useState<{ id: string; label: string } | null>(null);
|
||||
const [packageCount, setPackageCount] = useState(0);
|
||||
const [entries, setEntries] = useState<PackageEntry[]>([]);
|
||||
|
||||
const isPackageEnabled = packageConfig?.enabled ?? showPackageUnit ?? true;
|
||||
const showSummary = packageConfig?.showSummaryMessage !== false;
|
||||
|
||||
const entriesTotal = useMemo(
|
||||
() => entries.reduce((sum, e) => sum + e.totalQuantity, 0),
|
||||
[entries]
|
||||
);
|
||||
const remainingQuantity = Math.max(0, maxValue - entriesTotal);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDisplayValue(initialValue > 0 ? String(initialValue) : "");
|
||||
setPackageUnit(initialPackageUnit);
|
||||
setStep("quantity");
|
||||
setSelectedUnit(null);
|
||||
setPackageCount(0);
|
||||
setEntries([]);
|
||||
}
|
||||
}, [open, initialValue, initialPackageUnit]);
|
||||
}, [open, initialValue]);
|
||||
|
||||
// --- 키패드 핸들러 ---
|
||||
|
||||
const currentMax = step === "quantity"
|
||||
? maxValue
|
||||
: step === "package_count"
|
||||
? 9999
|
||||
: step === "quantity_per_unit"
|
||||
? Math.max(1, remainingQuantity)
|
||||
: maxValue;
|
||||
|
||||
const handleNumberClick = (num: string) => {
|
||||
const newStr = displayValue + num;
|
||||
const numericValue = parseInt(newStr, 10);
|
||||
setDisplayValue(numericValue > maxValue ? String(maxValue) : newStr);
|
||||
setDisplayValue(numericValue > currentMax ? String(currentMax) : newStr);
|
||||
};
|
||||
|
||||
const handleBackspace = () =>
|
||||
setDisplayValue((prev) => prev.slice(0, -1));
|
||||
const handleClear = () => setDisplayValue("");
|
||||
const handleMax = () => setDisplayValue(String(maxValue));
|
||||
const handleMax = () => setDisplayValue(String(currentMax));
|
||||
|
||||
// --- 확인 버튼: step에 따라 다르게 동작 ---
|
||||
|
||||
const handleConfirm = () => {
|
||||
const numericValue = parseInt(displayValue, 10) || 0;
|
||||
|
||||
if (step === "quantity") {
|
||||
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);
|
||||
};
|
||||
|
||||
const handlePackageUnitSelect = (selected: PackageUnit) => {
|
||||
setPackageUnit(selected);
|
||||
const handleBack = () => {
|
||||
if (step === "package_count") {
|
||||
setStep("quantity");
|
||||
setSelectedUnit(null);
|
||||
setDisplayValue("");
|
||||
} else if (step === "quantity_per_unit") {
|
||||
setStep("package_count");
|
||||
setDisplayValue(String(packageCount));
|
||||
} else if (step === "summary") {
|
||||
if (entries.length > 0) {
|
||||
const last = entries[entries.length - 1];
|
||||
setEntries((prev) => prev.slice(0, -1));
|
||||
setSelectedUnit({ id: last.unitId, label: last.unitLabel });
|
||||
setPackageCount(last.packageCount);
|
||||
setDisplayValue(String(last.quantityPerUnit));
|
||||
setStep("quantity_per_unit");
|
||||
} else {
|
||||
setStep("quantity");
|
||||
setDisplayValue("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const matchedUnit = packageUnit
|
||||
? PACKAGE_UNITS.find((u) => u.value === packageUnit)
|
||||
: null;
|
||||
const packageUnitLabel = matchedUnit?.label ?? null;
|
||||
const packageUnitEmoji = matchedUnit?.emoji ?? "📦";
|
||||
// --- 안내 메시지 ---
|
||||
|
||||
const guideMessage = useMemo(() => {
|
||||
switch (step) {
|
||||
case "quantity":
|
||||
return "수량을 입력하세요";
|
||||
case "package_count":
|
||||
return `${selectedUnit?.label || "포장"}을(를) 몇 개 사용하시나요?`;
|
||||
case "quantity_per_unit":
|
||||
return `${selectedUnit?.label || "포장"} 1개에 몇 ${unit} 넣으시나요?`;
|
||||
case "summary":
|
||||
return "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}, [step, selectedUnit, unit]);
|
||||
|
||||
// --- 헤더 정보 ---
|
||||
|
||||
const headerLabel = useMemo(() => {
|
||||
if (step === "summary") {
|
||||
return `등록: ${entriesTotal.toLocaleString()} ${unit} / 남은: ${remainingQuantity.toLocaleString()} ${unit}`;
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
return `남은 ${remainingQuantity.toLocaleString()} ${unit}`;
|
||||
}
|
||||
return `최대 ${maxValue.toLocaleString()} ${unit}`;
|
||||
}, [step, entriesTotal, remainingQuantity, maxValue, unit, entries.length]);
|
||||
|
||||
const displayText = displayValue
|
||||
? parseInt(displayValue, 10).toLocaleString()
|
||||
: "";
|
||||
|
||||
const isBackVisible = step !== "quantity";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -86,21 +230,111 @@ export function NumberInputModal({
|
|||
<DialogPrimitive.Content
|
||||
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] w-full max-w-[95vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden border shadow-lg duration-200 sm:max-w-[360px] sm:rounded-lg"
|
||||
>
|
||||
{/* 파란 헤더 */}
|
||||
<VisuallyHidden><DialogTitle>수량 입력</DialogTitle></VisuallyHidden>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-blue-500 px-4 py-3">
|
||||
<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">
|
||||
최대 {maxValue.toLocaleString()} {unit}
|
||||
{headerLabel}
|
||||
</span>
|
||||
</div>
|
||||
{isPackageEnabled && step === "quantity" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPackageModalOpen(true)}
|
||||
className="flex items-center gap-1 rounded-full bg-white/20 px-3 py-1.5 text-sm font-medium text-white hover:bg-white/30 active:bg-white/40"
|
||||
>
|
||||
{packageUnitEmoji} {packageUnitLabel ? `${packageUnitLabel} ✓` : "포장등록"}
|
||||
포장등록
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{displayText ? (
|
||||
|
|
@ -112,14 +346,13 @@ export function NumberInputModal({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 텍스트 */}
|
||||
{/* 단계별 안내 텍스트 */}
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
수량을 입력하세요
|
||||
{guideMessage}
|
||||
</p>
|
||||
|
||||
{/* 키패드 4x4 */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{/* 1행: 7 8 9 ← (주황) */}
|
||||
{["7", "8", "9"].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
|
|
@ -138,7 +371,6 @@ export function NumberInputModal({
|
|||
<Delete className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 2행: 4 5 6 C (주황) */}
|
||||
{["4", "5", "6"].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
|
|
@ -157,7 +389,6 @@ export function NumberInputModal({
|
|||
C
|
||||
</button>
|
||||
|
||||
{/* 3행: 1 2 3 MAX (파란) */}
|
||||
{["1", "2", "3"].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
|
|
@ -176,7 +407,6 @@ export function NumberInputModal({
|
|||
MAX
|
||||
</button>
|
||||
|
||||
{/* 4행: 0 / 확인 (초록, 3칸) */}
|
||||
<button
|
||||
type="button"
|
||||
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
||||
|
|
@ -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"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
확인
|
||||
{step === "package_count" ? "다음" : "확인"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</DialogPrimitive.Content>
|
||||
|
|
@ -201,8 +433,18 @@ export function NumberInputModal({
|
|||
{/* 포장 단위 선택 모달 */}
|
||||
<PackageUnitModal
|
||||
open={isPackageModalOpen}
|
||||
onOpenChange={setIsPackageModalOpen}
|
||||
onSelect={handlePackageUnitSelect}
|
||||
onOpenChange={(isOpen) => {
|
||||
setIsPackageModalOpen(isOpen);
|
||||
if (!isOpen && step === "summary") {
|
||||
// summary에서 추가 포장 모달 닫힘 -> 단위 선택 안 한 경우 유지
|
||||
}
|
||||
}}
|
||||
onSelect={(unitId) => {
|
||||
handlePackageUnitSelect(unitId);
|
||||
setIsPackageModalOpen(false);
|
||||
}}
|
||||
enabledUnits={packageConfig?.enabledUnits}
|
||||
customUnits={packageConfig?.customUnits}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@
|
|||
|
||||
import React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { CustomPackageUnit } from "../types";
|
||||
|
||||
export const PACKAGE_UNITS = [
|
||||
{ value: "box", label: "박스", emoji: "📦" },
|
||||
|
|
@ -24,19 +27,33 @@ export type PackageUnit = (typeof PACKAGE_UNITS)[number]["value"];
|
|||
interface PackageUnitModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (unit: PackageUnit) => void;
|
||||
onSelect: (unit: string) => void;
|
||||
enabledUnits?: string[];
|
||||
customUnits?: CustomPackageUnit[];
|
||||
}
|
||||
|
||||
export function PackageUnitModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
enabledUnits,
|
||||
customUnits,
|
||||
}: PackageUnitModalProps) {
|
||||
const handleSelect = (unit: PackageUnit) => {
|
||||
onSelect(unit);
|
||||
const handleSelect = (unitValue: string) => {
|
||||
onSelect(unitValue);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// enabledUnits가 undefined면 전체 표시, 배열이면 필터링
|
||||
const filteredDefaults = enabledUnits
|
||||
? PACKAGE_UNITS.filter((u) => enabledUnits.includes(u.value))
|
||||
: [...PACKAGE_UNITS];
|
||||
|
||||
const allUnits = [
|
||||
...filteredDefaults.map((u) => ({ value: u.value, label: u.label, emoji: u.emoji })),
|
||||
...(customUnits || []).map((cu) => ({ value: cu.id, label: cu.label, emoji: "📦" })),
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
|
|
@ -45,18 +62,17 @@ export function PackageUnitModal({
|
|||
<DialogPrimitive.Content
|
||||
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-1100 w-full max-w-[90vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-lg border shadow-lg duration-200 sm:max-w-[380px]"
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<VisuallyHidden><DialogTitle>포장 단위 선택</DialogTitle></VisuallyHidden>
|
||||
<div className="border-b px-4 py-3 pr-12">
|
||||
<h2 className="text-base font-semibold">📦 포장 단위 선택</h2>
|
||||
<h2 className="text-base font-semibold">포장 단위 선택</h2>
|
||||
</div>
|
||||
|
||||
{/* 3x2 그리드 */}
|
||||
<div className="grid grid-cols-3 gap-3 p-4">
|
||||
{PACKAGE_UNITS.map((unit) => (
|
||||
{allUnits.map((unit) => (
|
||||
<button
|
||||
key={unit.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(unit.value as PackageUnit)}
|
||||
onClick={() => handleSelect(unit.value)}
|
||||
className="hover:bg-muted active:bg-muted/70 flex flex-col items-center justify-center gap-2 rounded-xl border bg-background px-3 py-5 text-sm font-medium transition-colors"
|
||||
>
|
||||
<span className="text-2xl">{unit.emoji}</span>
|
||||
|
|
@ -65,7 +81,12 @@ export function PackageUnitModal({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* X 닫기 버튼 */}
|
||||
{allUnits.length === 0 && (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
사용 가능한 포장 단위가 없습니다
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogClose className="ring-offset-background focus:ring-ring absolute top-3 right-3 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@
|
|||
|
||||
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X } from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import {
|
||||
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight,
|
||||
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type {
|
||||
|
|
@ -20,10 +23,11 @@ import type {
|
|||
CardTemplateConfig,
|
||||
CardFieldBinding,
|
||||
CardInputFieldConfig,
|
||||
CardCalculatedFieldConfig,
|
||||
CardCartActionConfig,
|
||||
CardPackageConfig,
|
||||
CardPresetSpec,
|
||||
CartItem,
|
||||
PackageEntry,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_CARD_IMAGE,
|
||||
|
|
@ -31,13 +35,16 @@ import {
|
|||
} from "../types";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||
import { useCartSync } from "@/hooks/pop/useCartSync";
|
||||
import { NumberInputModal } from "./NumberInputModal";
|
||||
|
||||
// Lucide 아이콘 동적 렌더링
|
||||
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
|
||||
};
|
||||
|
||||
function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
|
||||
if (!name) return <ShoppingCart size={size} />;
|
||||
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
|
||||
const IconComp = icons[name];
|
||||
const IconComp = LUCIDE_ICON_MAP[name];
|
||||
if (!IconComp) return <ShoppingCart size={size} />;
|
||||
return <IconComp size={size} />;
|
||||
}
|
||||
|
|
@ -157,25 +164,92 @@ export function PopCardListComponent({
|
|||
const dataSource = config?.dataSource;
|
||||
const template = config?.cardTemplate;
|
||||
|
||||
// 이벤트 기반 company_code 필터링
|
||||
const [eventCompanyCode, setEventCompanyCode] = useState<string | undefined>();
|
||||
const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default");
|
||||
const { subscribe, publish } = usePopEvent(screenId || "default");
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!screenId) return;
|
||||
const unsub = subscribe("company_selected", (payload: unknown) => {
|
||||
const p = payload as { companyCode?: string } | undefined;
|
||||
setEventCompanyCode(p?.companyCode);
|
||||
});
|
||||
return unsub;
|
||||
}, [screenId, subscribe]);
|
||||
// 장바구니 DB 동기화
|
||||
const sourceTableName = dataSource?.tableName || "";
|
||||
const cartType = config?.cartAction?.cartType;
|
||||
const cart = useCartSync(screenId || "", sourceTableName, cartType);
|
||||
|
||||
// 데이터 상태
|
||||
const [rows, setRows] = useState<RowData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합)
|
||||
const [externalFilters, setExternalFilters] = useState<
|
||||
Map<string, {
|
||||
fieldName: string;
|
||||
value: unknown;
|
||||
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
|
||||
}>
|
||||
>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__filter_condition`,
|
||||
(payload: unknown) => {
|
||||
const data = payload as {
|
||||
value?: { fieldName?: string; value?: unknown };
|
||||
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
|
||||
_connectionId?: string;
|
||||
};
|
||||
const connId = data?._connectionId || "default";
|
||||
setExternalFilters(prev => {
|
||||
const next = new Map(prev);
|
||||
if (data?.value?.value) {
|
||||
next.set(connId, {
|
||||
fieldName: data.value.fieldName || "",
|
||||
value: data.value.value,
|
||||
filterConfig: data.filterConfig,
|
||||
});
|
||||
} else {
|
||||
next.delete(connId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe]);
|
||||
|
||||
// cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용
|
||||
const cartRef = useRef(cart);
|
||||
cartRef.current = cart;
|
||||
|
||||
// "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행
|
||||
useEffect(() => {
|
||||
if (!componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__cart_save_trigger`,
|
||||
async (payload: unknown) => {
|
||||
const data = payload as { value?: { selectedColumns?: string[] } } | undefined;
|
||||
const ok = await cartRef.current.saveToDb(data?.value?.selectedColumns);
|
||||
publish(`__comp_output__${componentId}__cart_save_completed`, {
|
||||
success: ok,
|
||||
});
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe, publish]);
|
||||
|
||||
// DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달
|
||||
useEffect(() => {
|
||||
if (!componentId || cart.loading) return;
|
||||
publish(`__comp_output__${componentId}__cart_updated`, {
|
||||
count: cart.cartCount,
|
||||
isDirty: cart.isDirty,
|
||||
});
|
||||
}, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish]);
|
||||
|
||||
// 카드 선택 시 selected_row 이벤트 발행
|
||||
const handleCardSelect = useCallback((row: RowData) => {
|
||||
if (!componentId) return;
|
||||
publish(`__comp_output__${componentId}__selected_row`, row);
|
||||
}, [componentId, publish]);
|
||||
|
||||
// 확장/페이지네이션 상태
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
|
@ -190,7 +264,9 @@ export function PopCardListComponent({
|
|||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0) setContainerWidth(width);
|
||||
if (height > 0) setContainerHeight(height);
|
||||
});
|
||||
|
|
@ -200,9 +276,10 @@ export function PopCardListComponent({
|
|||
|
||||
// 이미지 URL 없는 항목 카운트 (toast 중복 방지용)
|
||||
const missingImageCountRef = useRef(0);
|
||||
const toastShownRef = useRef(false);
|
||||
|
||||
const spec: CardPresetSpec = CARD_PRESET_SPECS.large;
|
||||
|
||||
const cardSizeKey = config?.cardSize || "large";
|
||||
const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large;
|
||||
|
||||
// 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열
|
||||
const maxAllowedColumns = useMemo(() => {
|
||||
|
|
@ -216,67 +293,82 @@ export function PopCardListComponent({
|
|||
const autoColumns = containerWidth > 0
|
||||
? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap)))
|
||||
: maxGridColumns;
|
||||
const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns);
|
||||
const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns));
|
||||
|
||||
// 컨테이너 높이 기반 행 수 자동 계산 (잘림 방지)
|
||||
const effectiveGridRows = useMemo(() => {
|
||||
if (containerHeight <= 0) return configGridRows;
|
||||
// 행 수: 설정값 그대로 사용 (containerHeight 연동 시 피드백 루프 위험)
|
||||
const gridRows = configGridRows;
|
||||
|
||||
const controlBarHeight = 44;
|
||||
const effectiveHeight = baseContainerHeight.current > 0
|
||||
? baseContainerHeight.current
|
||||
: containerHeight;
|
||||
const availableHeight = effectiveHeight - controlBarHeight;
|
||||
|
||||
const cardHeightWithGap = spec.height + spec.gap;
|
||||
const fittableRows = Math.max(1, Math.floor(
|
||||
(availableHeight + spec.gap) / cardHeightWithGap
|
||||
));
|
||||
|
||||
return Math.min(configGridRows, fittableRows);
|
||||
}, [containerHeight, configGridRows, spec]);
|
||||
|
||||
const gridRows = effectiveGridRows;
|
||||
|
||||
// 카드 크기: 컨테이너 실측 크기에서 gridColumns x gridRows 기준으로 동적 계산
|
||||
// 카드 크기: 높이는 프리셋 고정, 너비만 컨테이너 기반 동적 계산
|
||||
// (높이를 containerHeight에 연동하면 뷰어 모드의 minmax(auto) 그리드와
|
||||
// ResizeObserver 사이에서 피드백 루프가 발생해 무한 성장함)
|
||||
const scaled = useMemo((): ScaledConfig => {
|
||||
const gap = spec.gap;
|
||||
const controlBarHeight = 44;
|
||||
|
||||
const buildScaledConfig = (cardWidth: number, cardHeight: number): ScaledConfig => {
|
||||
const scale = cardHeight / spec.height;
|
||||
const cardHeight = spec.height;
|
||||
const minCardWidth = Math.round(spec.height * 1.6);
|
||||
|
||||
const cardWidth = containerWidth > 0
|
||||
? Math.max(minCardWidth,
|
||||
Math.floor((containerWidth - gap * (gridColumns - 1)) / gridColumns))
|
||||
: minCardWidth;
|
||||
|
||||
return {
|
||||
cardHeight,
|
||||
cardWidth,
|
||||
imageSize: Math.round(spec.imageSize * scale),
|
||||
padding: Math.round(spec.padding * scale),
|
||||
imageSize: spec.imageSize,
|
||||
padding: spec.padding,
|
||||
gap,
|
||||
headerPaddingX: Math.round(spec.headerPadX * scale),
|
||||
headerPaddingY: Math.round(spec.headerPadY * scale),
|
||||
codeTextSize: Math.round(spec.codeText * scale),
|
||||
titleTextSize: Math.round(spec.titleText * scale),
|
||||
bodyTextSize: Math.round(spec.bodyText * scale),
|
||||
};
|
||||
headerPaddingX: spec.headerPadX,
|
||||
headerPaddingY: spec.headerPadY,
|
||||
codeTextSize: spec.codeText,
|
||||
titleTextSize: spec.titleText,
|
||||
bodyTextSize: spec.bodyText,
|
||||
};
|
||||
}, [spec, containerWidth, gridColumns]);
|
||||
|
||||
if (containerWidth <= 0 || containerHeight <= 0) {
|
||||
return buildScaledConfig(Math.round(spec.height * 1.6), spec.height);
|
||||
// 외부 필터 적용 (복수 필터 AND 결합)
|
||||
const filteredRows = useMemo(() => {
|
||||
if (externalFilters.size === 0) return rows;
|
||||
|
||||
const matchSingleFilter = (
|
||||
row: RowData,
|
||||
filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } }
|
||||
): boolean => {
|
||||
const searchValue = String(filter.value).toLowerCase();
|
||||
if (!searchValue) return true;
|
||||
|
||||
const fc = filter.filterConfig;
|
||||
const columns: string[] =
|
||||
fc?.targetColumns?.length
|
||||
? fc.targetColumns
|
||||
: fc?.targetColumn
|
||||
? [fc.targetColumn]
|
||||
: filter.fieldName
|
||||
? [filter.fieldName]
|
||||
: [];
|
||||
|
||||
if (columns.length === 0) return true;
|
||||
|
||||
const mode = fc?.filterMode || "contains";
|
||||
|
||||
const matchCell = (cellValue: string) => {
|
||||
switch (mode) {
|
||||
case "equals":
|
||||
return cellValue === searchValue;
|
||||
case "starts_with":
|
||||
return cellValue.startsWith(searchValue);
|
||||
case "contains":
|
||||
default:
|
||||
return cellValue.includes(searchValue);
|
||||
}
|
||||
};
|
||||
|
||||
const effectiveHeight = baseContainerHeight.current > 0
|
||||
? baseContainerHeight.current
|
||||
: containerHeight;
|
||||
return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase()));
|
||||
};
|
||||
|
||||
const availableHeight = effectiveHeight - controlBarHeight;
|
||||
const availableWidth = containerWidth;
|
||||
|
||||
const cardHeight = Math.max(spec.height,
|
||||
Math.floor((availableHeight - gap * (gridRows - 1)) / gridRows));
|
||||
const cardWidth = Math.max(Math.round(spec.height * 1.6),
|
||||
Math.floor((availableWidth - gap * (gridColumns - 1)) / gridColumns));
|
||||
|
||||
return buildScaledConfig(cardWidth, cardHeight);
|
||||
}, [spec, containerWidth, containerHeight, gridColumns, gridRows]);
|
||||
const allFilters = [...externalFilters.values()];
|
||||
return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f)));
|
||||
}, [rows, externalFilters]);
|
||||
|
||||
// 기본 상태에서 표시할 카드 수
|
||||
const visibleCardCount = useMemo(() => {
|
||||
|
|
@ -284,7 +376,7 @@ export function PopCardListComponent({
|
|||
}, [gridColumns, gridRows]);
|
||||
|
||||
// 더보기 버튼 표시 여부
|
||||
const hasMoreCards = rows.length > visibleCardCount;
|
||||
const hasMoreCards = filteredRows.length > visibleCardCount;
|
||||
|
||||
// 확장 상태에서 표시할 카드 수 계산
|
||||
const expandedCardsPerPage = useMemo(() => {
|
||||
|
|
@ -300,19 +392,17 @@ export function PopCardListComponent({
|
|||
// 현재 표시할 카드 결정
|
||||
const displayCards = useMemo(() => {
|
||||
if (!isExpanded) {
|
||||
// 기본 상태: visibleCardCount만큼만 표시
|
||||
return rows.slice(0, visibleCardCount);
|
||||
return filteredRows.slice(0, visibleCardCount);
|
||||
} else {
|
||||
// 확장 상태: 현재 페이지의 카드들 (스크롤 가능하도록 더 많이)
|
||||
const start = (currentPage - 1) * expandedCardsPerPage;
|
||||
const end = start + expandedCardsPerPage;
|
||||
return rows.slice(start, end);
|
||||
return filteredRows.slice(start, end);
|
||||
}
|
||||
}, [rows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]);
|
||||
}, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]);
|
||||
|
||||
// 총 페이지 수
|
||||
const totalPages = isExpanded
|
||||
? Math.ceil(rows.length / expandedCardsPerPage)
|
||||
? Math.ceil(filteredRows.length / expandedCardsPerPage)
|
||||
: 1;
|
||||
// 페이지네이션 필요: 확장 상태에서 전체 카드가 한 페이지를 초과할 때
|
||||
const needsPagination = isExpanded && totalPages > 1;
|
||||
|
|
@ -358,7 +448,12 @@ export function PopCardListComponent({
|
|||
}
|
||||
}, [currentPage, isExpanded]);
|
||||
|
||||
// 데이터 조회
|
||||
// dataSource를 직렬화해서 의존성 안정화 (객체 참조 변경에 의한 불필요한 재호출 방지)
|
||||
const dataSourceKey = useMemo(
|
||||
() => JSON.stringify(dataSource || null),
|
||||
[dataSource]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dataSource?.tableName) {
|
||||
setLoading(false);
|
||||
|
|
@ -370,10 +465,8 @@ export function PopCardListComponent({
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
missingImageCountRef.current = 0;
|
||||
toastShownRef.current = false;
|
||||
|
||||
try {
|
||||
// 필터 조건 구성
|
||||
const filters: Record<string, unknown> = {};
|
||||
if (dataSource.filters && dataSource.filters.length > 0) {
|
||||
dataSource.filters.forEach((f) => {
|
||||
|
|
@ -383,28 +476,25 @@ export function PopCardListComponent({
|
|||
});
|
||||
}
|
||||
|
||||
// 이벤트로 수신한 company_code 필터 병합
|
||||
if (eventCompanyCode) {
|
||||
filters["company_code"] = eventCompanyCode;
|
||||
}
|
||||
// 다중 정렬: 첫 번째 기준을 서버 정렬로 전달 (하위 호환: 단일 객체도 처리)
|
||||
const sortArray = Array.isArray(dataSource.sort)
|
||||
? dataSource.sort
|
||||
: dataSource.sort && typeof dataSource.sort === "object"
|
||||
? [dataSource.sort as { column: string; direction: "asc" | "desc" }]
|
||||
: [];
|
||||
const primarySort = sortArray.length > 0 ? sortArray[0] : undefined;
|
||||
const sortBy = primarySort?.column;
|
||||
const sortOrder = primarySort?.direction;
|
||||
|
||||
// 정렬 조건
|
||||
const sortBy = dataSource.sort?.column;
|
||||
const sortOrder = dataSource.sort?.direction;
|
||||
|
||||
// 개수 제한
|
||||
const size =
|
||||
dataSource.limit?.mode === "limited" && dataSource.limit?.count
|
||||
? dataSource.limit.count
|
||||
: 100;
|
||||
|
||||
// TODO: 조인 지원은 추후 구현
|
||||
// 현재는 단일 테이블 조회만 지원
|
||||
|
||||
const result = await dataApi.getTableData(dataSource.tableName, {
|
||||
page: 1,
|
||||
size,
|
||||
sortBy: sortOrder ? sortBy : undefined,
|
||||
sortBy: sortBy || undefined,
|
||||
sortOrder,
|
||||
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
||||
});
|
||||
|
|
@ -420,27 +510,13 @@ export function PopCardListComponent({
|
|||
};
|
||||
|
||||
fetchData();
|
||||
}, [dataSource, eventCompanyCode]);
|
||||
}, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 이미지 URL 없는 항목 체크 및 toast 표시
|
||||
// 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시)
|
||||
useEffect(() => {
|
||||
if (
|
||||
!loading &&
|
||||
rows.length > 0 &&
|
||||
template?.image?.enabled &&
|
||||
template?.image?.imageColumn &&
|
||||
!toastShownRef.current
|
||||
) {
|
||||
if (!loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn) {
|
||||
const imageColumn = template.image.imageColumn;
|
||||
const missingCount = rows.filter((row) => !row[imageColumn]).length;
|
||||
|
||||
if (missingCount > 0) {
|
||||
missingImageCountRef.current = missingCount;
|
||||
toastShownRef.current = true;
|
||||
toast.warning(
|
||||
`${missingCount}개 항목의 이미지 URL이 없어 기본 이미지로 표시됩니다`
|
||||
);
|
||||
}
|
||||
missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length;
|
||||
}
|
||||
}, [loading, rows, template?.image]);
|
||||
|
||||
|
|
@ -503,21 +579,29 @@ export function PopCardListComponent({
|
|||
justifyContent: isHorizontalMode ? "start" : "center",
|
||||
}}
|
||||
>
|
||||
{displayCards.map((row, index) => (
|
||||
{displayCards.map((row, index) => {
|
||||
const codeValue = template?.header?.codeField && row[template.header.codeField]
|
||||
? String(row[template.header.codeField])
|
||||
: null;
|
||||
const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`;
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
key={rowKey}
|
||||
row={row}
|
||||
template={template}
|
||||
scaled={scaled}
|
||||
inputField={config?.inputField}
|
||||
calculatedField={config?.calculatedField}
|
||||
packageConfig={config?.packageConfig}
|
||||
cartAction={config?.cartAction}
|
||||
publish={publish}
|
||||
getSharedData={getSharedData}
|
||||
setSharedData={setSharedData}
|
||||
router={router}
|
||||
onSelect={handleCardSelect}
|
||||
cart={cart}
|
||||
codeFieldName={template?.header?.codeField}
|
||||
parentComponentId={componentId}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 하단 컨트롤 영역 */}
|
||||
|
|
@ -544,7 +628,7 @@ export function PopCardListComponent({
|
|||
)}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rows.length}건
|
||||
{filteredRows.length}건{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}건` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -589,69 +673,75 @@ function Card({
|
|||
template,
|
||||
scaled,
|
||||
inputField,
|
||||
calculatedField,
|
||||
packageConfig,
|
||||
cartAction,
|
||||
publish,
|
||||
getSharedData,
|
||||
setSharedData,
|
||||
router,
|
||||
onSelect,
|
||||
cart,
|
||||
codeFieldName,
|
||||
parentComponentId,
|
||||
}: {
|
||||
row: RowData;
|
||||
template?: CardTemplateConfig;
|
||||
scaled: ScaledConfig;
|
||||
inputField?: CardInputFieldConfig;
|
||||
calculatedField?: CardCalculatedFieldConfig;
|
||||
packageConfig?: CardPackageConfig;
|
||||
cartAction?: CardCartActionConfig;
|
||||
publish: (eventName: string, payload?: unknown) => void;
|
||||
getSharedData: <T = unknown>(key: string) => T | undefined;
|
||||
setSharedData: (key: string, value: unknown) => void;
|
||||
router: ReturnType<typeof useRouter>;
|
||||
onSelect?: (row: RowData) => void;
|
||||
cart: ReturnType<typeof useCartSync>;
|
||||
codeFieldName?: string;
|
||||
parentComponentId?: string;
|
||||
}) {
|
||||
const header = template?.header;
|
||||
const image = template?.image;
|
||||
const body = template?.body;
|
||||
|
||||
// 입력 필드 상태
|
||||
const [inputValue, setInputValue] = useState<number>(
|
||||
inputField?.defaultValue || 0
|
||||
);
|
||||
const [inputValue, setInputValue] = useState<number>(0);
|
||||
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
|
||||
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// 담기/취소 토글 상태
|
||||
const [isCarted, setIsCarted] = useState(false);
|
||||
|
||||
// 헤더 값 추출
|
||||
const codeValue = header?.codeField ? row[header.codeField] : null;
|
||||
const titleValue = header?.titleField ? row[header.titleField] : null;
|
||||
|
||||
// 이미지 URL 결정
|
||||
// 장바구니 상태: codeField 값을 rowKey로 사용
|
||||
const rowKey = codeFieldName && row[codeFieldName] ? String(row[codeFieldName]) : "";
|
||||
const isCarted = cart.isItemInCart(rowKey);
|
||||
const existingCartItem = cart.getCartItem(rowKey);
|
||||
|
||||
// DB에서 로드된 장바구니 품목이면 입력값 복원
|
||||
useEffect(() => {
|
||||
if (existingCartItem && existingCartItem._origin === "db") {
|
||||
setInputValue(existingCartItem.quantity);
|
||||
setPackageUnit(existingCartItem.packageUnit);
|
||||
setPackageEntries(existingCartItem.packageEntries || []);
|
||||
}
|
||||
}, [existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]);
|
||||
|
||||
const imageUrl =
|
||||
image?.enabled && image?.imageColumn && row[image.imageColumn]
|
||||
? String(row[image.imageColumn])
|
||||
: image?.defaultImage || DEFAULT_CARD_IMAGE;
|
||||
|
||||
// 계산 필드 값 계산
|
||||
const calculatedValue = useMemo(() => {
|
||||
if (!calculatedField?.enabled || !calculatedField?.formula) return null;
|
||||
return evaluateFormula(calculatedField.formula, row, inputValue);
|
||||
}, [calculatedField, row, inputValue]);
|
||||
|
||||
// effectiveMax: DB 컬럼 우선, 없으면 inputField.max 폴백
|
||||
// limitColumn 우선, 하위 호환으로 maxColumn 폴백
|
||||
const limitCol = inputField?.limitColumn || inputField?.maxColumn;
|
||||
const effectiveMax = useMemo(() => {
|
||||
if (inputField?.maxColumn) {
|
||||
const colVal = Number(row[inputField.maxColumn]);
|
||||
if (limitCol) {
|
||||
const colVal = Number(row[limitCol]);
|
||||
if (!isNaN(colVal) && colVal > 0) return colVal;
|
||||
}
|
||||
return inputField?.max ?? 999999;
|
||||
}, [inputField, row]);
|
||||
return 999999;
|
||||
}, [limitCol, row]);
|
||||
|
||||
// 기본값이 설정되지 않은 경우 최대값으로 자동 초기화
|
||||
// 제한 컬럼이 있으면 최대값으로 자동 초기화
|
||||
useEffect(() => {
|
||||
if (inputField?.enabled && !inputField?.defaultValue && effectiveMax > 0 && effectiveMax < 999999) {
|
||||
if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
|
||||
setInputValue(effectiveMax);
|
||||
}
|
||||
}, [effectiveMax, inputField?.enabled, inputField?.defaultValue]);
|
||||
}, [effectiveMax, inputField?.enabled, limitCol]);
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
height: `${scaled.cardHeight}px`,
|
||||
|
|
@ -677,43 +767,43 @@ function Card({
|
|||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleInputConfirm = (value: number, unit?: string) => {
|
||||
const handleInputConfirm = (value: number, unit?: string, entries?: PackageEntry[]) => {
|
||||
setInputValue(value);
|
||||
setPackageUnit(unit);
|
||||
setPackageEntries(entries || []);
|
||||
};
|
||||
|
||||
// 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글
|
||||
// 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달
|
||||
const handleCartAdd = () => {
|
||||
if (!rowKey) return;
|
||||
|
||||
const cartItem: CartItem = {
|
||||
row,
|
||||
quantity: inputValue,
|
||||
packageUnit: packageUnit || undefined,
|
||||
packageEntries: packageEntries.length > 0 ? packageEntries : undefined,
|
||||
};
|
||||
|
||||
const existing = getSharedData<CartItem[]>("cart_items") || [];
|
||||
setSharedData("cart_items", [...existing, cartItem]);
|
||||
publish("cart_item_added", cartItem);
|
||||
|
||||
setIsCarted(true);
|
||||
toast.success("장바구니에 담겼습니다.");
|
||||
|
||||
if (cartAction?.navigateMode === "screen" && cartAction.targetScreenId) {
|
||||
router.push(`/pop/screens/${cartAction.targetScreenId}`);
|
||||
cart.addItem(cartItem, rowKey);
|
||||
if (parentComponentId) {
|
||||
publish(`__comp_output__${parentComponentId}__cart_updated`, {
|
||||
count: cart.cartCount + 1,
|
||||
isDirty: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 취소: sharedData에서 해당 아이템 제거 + 이벤트 발행 + 토글 복원
|
||||
// 취소: 로컬 상태에서만 제거 + 연결 시스템으로 카운트 전달
|
||||
const handleCartCancel = () => {
|
||||
const existing = getSharedData<CartItem[]>("cart_items") || [];
|
||||
const rowKey = JSON.stringify(row);
|
||||
const filtered = existing.filter(
|
||||
(item) => JSON.stringify(item.row) !== rowKey
|
||||
);
|
||||
setSharedData("cart_items", filtered);
|
||||
publish("cart_item_removed", { row });
|
||||
if (!rowKey) return;
|
||||
|
||||
setIsCarted(false);
|
||||
toast.info("장바구니에서 제거되었습니다.");
|
||||
cart.removeItem(rowKey);
|
||||
if (parentComponentId) {
|
||||
publish(`__comp_output__${parentComponentId}__cart_updated`, {
|
||||
count: Math.max(0, cart.cartCount - 1),
|
||||
isDirty: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영)
|
||||
|
|
@ -721,14 +811,26 @@ function Card({
|
|||
const cartLabel = cartAction?.label || "담기";
|
||||
const cancelLabel = cartAction?.cancelLabel || "취소";
|
||||
|
||||
const handleCardClick = () => {
|
||||
onSelect?.(row);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border bg-card shadow-sm transition-all duration-150 hover:border-2 hover:border-blue-500 hover:shadow-md"
|
||||
className={`cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${
|
||||
isCarted
|
||||
? "border-emerald-500 border-2 hover:border-emerald-600"
|
||||
: "hover:border-2 hover:border-blue-500"
|
||||
}`}
|
||||
style={cardStyle}
|
||||
onClick={handleCardClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }}
|
||||
>
|
||||
{/* 헤더 영역 */}
|
||||
{(codeValue !== null || titleValue !== null) && (
|
||||
<div className="border-b bg-muted/30" style={headerStyle}>
|
||||
<div className={`border-b ${isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30"}`} style={headerStyle}>
|
||||
<div className="flex items-center gap-2">
|
||||
{codeValue !== null && (
|
||||
<span
|
||||
|
|
@ -777,7 +879,7 @@ function Card({
|
|||
<div style={{ display: "flex", flexDirection: "column", gap: `${Math.round(scaled.gap / 2)}px` }}>
|
||||
{body?.fields && body.fields.length > 0 ? (
|
||||
body.fields.map((field) => (
|
||||
<FieldRow key={field.id} field={field} row={row} scaled={scaled} />
|
||||
<FieldRow key={field.id} field={field} row={row} scaled={scaled} inputValue={inputValue} />
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
|
|
@ -787,34 +889,17 @@ function Card({
|
|||
본문 필드를 추가하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 계산 필드 */}
|
||||
{calculatedField?.enabled && calculatedValue !== null && (
|
||||
<div
|
||||
className="flex items-baseline"
|
||||
style={{ gap: `${Math.round(scaled.gap / 2)}px`, fontSize: `${scaled.bodyTextSize}px` }}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 text-muted-foreground"
|
||||
style={{ minWidth: `${Math.round(50 * (scaled.bodyTextSize / 12))}px` }}
|
||||
>
|
||||
{calculatedField.label || "계산값"}
|
||||
</span>
|
||||
<MarqueeText className="font-medium text-orange-600">
|
||||
{calculatedValue.toLocaleString()}{calculatedField.unit ? ` ${calculatedField.unit}` : ""}
|
||||
</MarqueeText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (inputField 활성화 시만) */}
|
||||
{inputField?.enabled && (
|
||||
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */}
|
||||
{(inputField?.enabled || cartAction) && (
|
||||
<div
|
||||
className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2"
|
||||
style={{ minWidth: "100px" }}
|
||||
>
|
||||
{/* 수량 버튼 */}
|
||||
{/* 수량 버튼 (입력 필드 ON일 때만) */}
|
||||
{inputField?.enabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInputClick}
|
||||
|
|
@ -827,8 +912,11 @@ function Card({
|
|||
{inputField.unit || "EA"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* pop-icon 스타일 담기/취소 토글 버튼 */}
|
||||
{/* 담기/취소 버튼 (cartAction 존재 시 항상 표시) */}
|
||||
{cartAction && (
|
||||
<>
|
||||
{isCarted ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -844,7 +932,7 @@ function Card({
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleCartAdd}
|
||||
className="flex flex-col items-center justify-center gap-0.5 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700"
|
||||
className="flex flex-col items-center justify-center gap-0.5 rounded-xl bg-linear-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700"
|
||||
>
|
||||
{cartAction?.iconType === "emoji" && cartAction?.iconValue ? (
|
||||
<span style={{ fontSize: `${iconSize}px` }}>{cartAction.iconValue}</span>
|
||||
|
|
@ -856,11 +944,12 @@ function Card({
|
|||
</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 숫자 입력 모달 */}
|
||||
{inputField?.enabled && (
|
||||
<NumberInputModal
|
||||
open={isModalOpen}
|
||||
|
|
@ -868,8 +957,9 @@ function Card({
|
|||
unit={inputField.unit || "EA"}
|
||||
initialValue={inputValue}
|
||||
initialPackageUnit={packageUnit}
|
||||
min={inputField.min || 0}
|
||||
maxValue={effectiveMax}
|
||||
packageConfig={packageConfig}
|
||||
showPackageUnit={inputField.showPackageUnit}
|
||||
onConfirm={handleInputConfirm}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -883,14 +973,54 @@ function FieldRow({
|
|||
field,
|
||||
row,
|
||||
scaled,
|
||||
inputValue,
|
||||
}: {
|
||||
field: CardFieldBinding;
|
||||
row: RowData;
|
||||
scaled: ScaledConfig;
|
||||
inputValue?: number;
|
||||
}) {
|
||||
const value = row[field.columnName];
|
||||
const valueType = field.valueType || "column";
|
||||
|
||||
// 비율 기반 라벨 최소 너비
|
||||
const displayValue = useMemo(() => {
|
||||
if (valueType !== "formula") {
|
||||
return formatValue(field.columnName ? row[field.columnName] : undefined);
|
||||
}
|
||||
|
||||
// 구조화된 수식 우선
|
||||
if (field.formulaLeft && field.formulaOperator) {
|
||||
const rightVal = field.formulaRightType === "input"
|
||||
? (inputValue ?? 0)
|
||||
: Number(row[field.formulaRight || ""] ?? 0);
|
||||
const leftVal = Number(row[field.formulaLeft] ?? 0);
|
||||
|
||||
let result: number | null = null;
|
||||
switch (field.formulaOperator) {
|
||||
case "+": result = leftVal + rightVal; break;
|
||||
case "-": result = leftVal - rightVal; break;
|
||||
case "*": result = leftVal * rightVal; break;
|
||||
case "/": result = rightVal !== 0 ? leftVal / rightVal : null; break;
|
||||
}
|
||||
|
||||
if (result !== null && isFinite(result)) {
|
||||
const formatted = Math.round(result * 100) / 100;
|
||||
return field.unit ? `${formatted.toLocaleString()} ${field.unit}` : formatted.toLocaleString();
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
// 하위 호환: 레거시 formula 문자열
|
||||
if (field.formula) {
|
||||
const result = evaluateFormula(field.formula, row, inputValue ?? 0);
|
||||
if (result !== null) {
|
||||
const formatted = result.toLocaleString();
|
||||
return field.unit ? `${formatted} ${field.unit}` : formatted;
|
||||
}
|
||||
}
|
||||
return "-";
|
||||
}, [valueType, field, row, inputValue]);
|
||||
|
||||
const isFormula = valueType === "formula";
|
||||
const labelMinWidth = Math.round(50 * (scaled.bodyTextSize / 12));
|
||||
|
||||
return (
|
||||
|
|
@ -898,19 +1028,17 @@ function FieldRow({
|
|||
className="flex items-baseline"
|
||||
style={{ gap: `${Math.round(scaled.gap / 2)}px`, fontSize: `${scaled.bodyTextSize}px` }}
|
||||
>
|
||||
{/* 라벨 */}
|
||||
<span
|
||||
className="shrink-0 text-muted-foreground"
|
||||
style={{ minWidth: `${labelMinWidth}px` }}
|
||||
>
|
||||
{field.label}
|
||||
</span>
|
||||
{/* 값 - 기본 검정색, textColor 설정 시 해당 색상 */}
|
||||
<MarqueeText
|
||||
className="font-medium"
|
||||
style={{ color: field.textColor || "#000000" }}
|
||||
style={{ color: field.textColor || (isFormula ? "#ea580c" : "#000000") }}
|
||||
>
|
||||
{formatValue(value)}
|
||||
{displayValue}
|
||||
</MarqueeText>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -982,7 +1110,6 @@ function evaluateFormula(
|
|||
// 안전한 계산 (기본 산술 연산만 허용)
|
||||
// 허용: 숫자, +, -, *, /, (, ), 공백, 소수점
|
||||
if (!/^[\d\s+\-*/().]+$/.test(expression)) {
|
||||
console.warn("Invalid formula expression:", expression);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -995,7 +1122,7 @@ function evaluateFormula(
|
|||
|
||||
return Math.round(result * 100) / 100; // 소수점 2자리까지
|
||||
} catch (error) {
|
||||
console.warn("Formula evaluation error:", error);
|
||||
// 수식 평가 실패 시 null 반환
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -41,9 +41,7 @@ const defaultConfig: PopCardListConfig = {
|
|||
gridRows: 2,
|
||||
// 담기 버튼 기본 설정
|
||||
cartAction: {
|
||||
navigateMode: "none",
|
||||
iconType: "lucide",
|
||||
iconValue: "ShoppingCart",
|
||||
saveMode: "cart",
|
||||
label: "담기",
|
||||
cancelLabel: "취소",
|
||||
},
|
||||
|
|
@ -60,6 +58,17 @@ PopComponentRegistry.registerComponent({
|
|||
configPanel: PopCardListConfigPanel,
|
||||
preview: PopCardListPreviewComponent,
|
||||
defaultProps: defaultConfig,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "장바구니 변경 시 count/isDirty 전달" },
|
||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
|
||||
{ key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,20 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import type {
|
||||
PopSearchConfig,
|
||||
SearchInputType,
|
||||
|
|
@ -379,6 +392,7 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
|
|||
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
const [openTableCombo, setOpenTableCombo] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -455,23 +469,62 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
|
|||
테이블 목록 로딩...
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={mc.tableName || undefined}
|
||||
onValueChange={(v) =>
|
||||
updateModal({ tableName: v, displayColumns: [], searchColumns: [], displayField: "", valueField: "", columnLabels: undefined })
|
||||
}
|
||||
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openTableCombo}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mc.tableName
|
||||
? tables.find((t) => t.tableName === mc.tableName)?.displayName || mc.tableName
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="한글명 또는 영문 테이블명 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.tableName} value={t.tableName} className="text-xs">
|
||||
{t.displayName || t.tableName}
|
||||
</SelectItem>
|
||||
<CommandItem
|
||||
key={t.tableName}
|
||||
value={`${t.displayName || ""} ${t.tableName}`}
|
||||
onSelect={() => {
|
||||
updateModal({
|
||||
tableName: t.tableName,
|
||||
displayColumns: [],
|
||||
searchColumns: [],
|
||||
displayField: "",
|
||||
valueField: "",
|
||||
columnLabels: undefined,
|
||||
});
|
||||
setOpenTableCombo(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mc.tableName === t.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||
{t.displayName && t.displayName !== t.tableName && (
|
||||
<span className="text-[9px] text-muted-foreground">{t.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -364,11 +364,24 @@ export interface CardColumnFilter {
|
|||
|
||||
// ----- 본문 필드 바인딩 (라벨-값 쌍) -----
|
||||
|
||||
export type FieldValueType = "column" | "formula";
|
||||
export type FormulaOperator = "+" | "-" | "*" | "/";
|
||||
export type FormulaRightType = "column" | "input";
|
||||
|
||||
export interface CardFieldBinding {
|
||||
id: string;
|
||||
columnName: string; // DB 컬럼명
|
||||
label: string; // 표시 라벨 (예: "발주일")
|
||||
textColor?: string; // 텍스트 색상 (예: "#ef4444" 빨간색)
|
||||
label: string;
|
||||
valueType: FieldValueType;
|
||||
columnName?: string; // valueType === "column"일 때 DB 컬럼명
|
||||
// 구조화된 수식 (클릭형 빌더)
|
||||
formulaLeft?: string; // 왼쪽: DB 컬럼명
|
||||
formulaOperator?: FormulaOperator;
|
||||
formulaRightType?: FormulaRightType; // "column" 또는 "input"($input)
|
||||
formulaRight?: string; // rightType === "column"일 때 DB 컬럼명
|
||||
/** @deprecated 구조화 수식 필드 사용, 하위 호환용 */
|
||||
formula?: string;
|
||||
unit?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
// ----- 카드 헤더 설정 (코드 + 제목) -----
|
||||
|
|
@ -406,11 +419,16 @@ export interface CardTemplateConfig {
|
|||
|
||||
// ----- 데이터 소스 (테이블 단위) -----
|
||||
|
||||
export interface CardSortConfig {
|
||||
column: string;
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
export interface CardListDataSource {
|
||||
tableName: string;
|
||||
joins?: CardColumnJoin[];
|
||||
filters?: CardColumnFilter[];
|
||||
sort?: { column: string; direction: "asc" | "desc" };
|
||||
sort?: CardSortConfig[];
|
||||
limit?: { mode: "all" | "limited"; count?: number };
|
||||
}
|
||||
|
||||
|
|
@ -437,44 +455,84 @@ export const CARD_SCROLL_DIRECTION_LABELS: Record<CardScrollDirection, string> =
|
|||
|
||||
export interface CardInputFieldConfig {
|
||||
enabled: boolean;
|
||||
columnName?: string; // 입력값이 저장될 컬럼
|
||||
label?: string; // 표시 라벨 (예: "발주 수량")
|
||||
unit?: string; // 단위 (예: "EA", "개")
|
||||
defaultValue?: number; // 기본값
|
||||
min?: number; // 최소값
|
||||
max?: number; // 최대값
|
||||
maxColumn?: string; // 최대값을 DB 컬럼에서 동적으로 가져올 컬럼명 (설정 시 row[maxColumn] 우선)
|
||||
step?: number; // 증감 단위
|
||||
}
|
||||
|
||||
// ----- 카드 내 계산 필드 설정 -----
|
||||
|
||||
export interface CardCalculatedFieldConfig {
|
||||
enabled: boolean;
|
||||
label?: string; // 표시 라벨 (예: "미입고")
|
||||
formula: string; // 계산식 (예: "order_qty - inbound_qty")
|
||||
sourceColumns: string[]; // 계산에 사용되는 컬럼들
|
||||
resultColumn?: string; // 결과를 저장할 컬럼 (선택)
|
||||
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 {
|
||||
row: Record<string, unknown>; // 카드 원본 행 데이터
|
||||
quantity: number; // 입력 수량
|
||||
packageUnit?: string; // 포장 단위 (box/bag/pack/bundle/roll/barrel)
|
||||
packageEntries?: PackageEntry[]; // 포장 내역 (2단계 계산 시)
|
||||
}
|
||||
|
||||
// ----- 장바구니 DB 연동용 확장 타입 -----
|
||||
|
||||
export type CartSyncStatus = "clean" | "dirty" | "saving";
|
||||
export type CartItemOrigin = "db" | "local";
|
||||
export type CartItemStatus = "in_cart" | "confirmed" | "cancelled";
|
||||
|
||||
export interface CartItemWithId extends CartItem {
|
||||
cartId?: string; // DB id (UUID, 저장 후 할당)
|
||||
sourceTable: string; // 원본 테이블명
|
||||
rowKey: string; // 원본 행 식별키 (codeField 값)
|
||||
status: CartItemStatus;
|
||||
_origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
|
||||
// ----- 담기 버튼 액션 설정 (pop-icon 스타일 + 장바구니 연동) -----
|
||||
|
||||
|
||||
export type CartSaveMode = "cart" | "direct";
|
||||
|
||||
export interface CardCartActionConfig {
|
||||
navigateMode: "none" | "screen"; // 담기 후 이동 모드
|
||||
targetScreenId?: string; // 장바구니 POP 화면 ID (screen 모드)
|
||||
iconType?: "lucide" | "emoji"; // 아이콘 타입
|
||||
iconValue?: string; // Lucide 아이콘명 또는 이모지 값
|
||||
saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장
|
||||
cartType?: string; // 장바구니 구분값 (예: "purchase_inbound")
|
||||
label?: string; // 담기 라벨 (기본: "담기")
|
||||
cancelLabel?: string; // 취소 라벨 (기본: "취소")
|
||||
// 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호)
|
||||
dataFields?: { sourceField: string; targetField?: string; label?: string }[];
|
||||
// 하위 호환: 기존 필드 (사용하지 않지만 기존 데이터 보호)
|
||||
navigateMode?: "none" | "screen";
|
||||
targetScreenId?: string;
|
||||
iconType?: "lucide" | "emoji";
|
||||
iconValue?: string;
|
||||
}
|
||||
|
||||
// ----- pop-card-list 전체 설정 -----
|
||||
|
|
@ -533,31 +591,39 @@ export const CARD_PRESET_SPECS: Record<CardSize, CardPresetSpec> = {
|
|||
},
|
||||
};
|
||||
|
||||
// ----- 반응형 표시 모드 -----
|
||||
|
||||
export type ResponsiveDisplayMode = "required" | "shrink" | "hidden";
|
||||
|
||||
export const RESPONSIVE_DISPLAY_LABELS: Record<ResponsiveDisplayMode, string> = {
|
||||
required: "필수",
|
||||
shrink: "축소",
|
||||
hidden: "숨김",
|
||||
};
|
||||
|
||||
export interface CardResponsiveConfig {
|
||||
code?: ResponsiveDisplayMode;
|
||||
title?: ResponsiveDisplayMode;
|
||||
image?: ResponsiveDisplayMode;
|
||||
fields?: Record<string, ResponsiveDisplayMode>;
|
||||
}
|
||||
|
||||
// ----- pop-card-list 전체 설정 -----
|
||||
|
||||
export interface PopCardListConfig {
|
||||
// 데이터 소스 (테이블 단위)
|
||||
dataSource: CardListDataSource;
|
||||
|
||||
// 카드 템플릿 (헤더 + 이미지 + 본문)
|
||||
cardTemplate: CardTemplateConfig;
|
||||
|
||||
// 스크롤 방향
|
||||
scrollDirection: CardScrollDirection;
|
||||
cardsPerRow?: number; // deprecated, gridColumns 사용
|
||||
cardSize: CardSize; // 프리셋 크기 (small/medium/large)
|
||||
cardSize: CardSize;
|
||||
|
||||
// 그리드 배치 설정 (가로 x 세로)
|
||||
gridColumns?: number; // 가로 카드 수 (기본값: 3)
|
||||
gridRows?: number; // 세로 카드 수 (기본값: 2)
|
||||
gridColumns?: number;
|
||||
gridRows?: number;
|
||||
|
||||
// 확장 설정 (더보기 클릭 시 그리드 rowSpan 변경)
|
||||
// expandedRowSpan 제거됨 - 항상 원래 크기의 2배로 확장
|
||||
// 반응형 표시 설정
|
||||
responsiveDisplay?: CardResponsiveConfig;
|
||||
|
||||
// 입력 필드 설정 (수량 입력 등)
|
||||
inputField?: CardInputFieldConfig;
|
||||
|
||||
// 계산 필드 설정 (미입고 등 자동 계산)
|
||||
calculatedField?: CardCalculatedFieldConfig;
|
||||
|
||||
// 담기 버튼 액션 설정 (pop-icon 스타일)
|
||||
packageConfig?: CardPackageConfig;
|
||||
cartAction?: CardCartActionConfig;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -558,31 +558,7 @@ export class ButtonActionExecutor {
|
|||
return false;
|
||||
}
|
||||
|
||||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||
// 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,
|
||||
});
|
||||
|
||||
// beforeFormSave 이벤트 발송 (BomItemEditor 등 서브 컴포넌트의 저장 처리)
|
||||
const beforeSaveEventDetail = {
|
||||
formData: context.formData,
|
||||
skipDefaultSave: false,
|
||||
|
|
@ -596,22 +572,28 @@ export class ButtonActionExecutor {
|
|||
}),
|
||||
);
|
||||
|
||||
// 비동기 핸들러가 등록한 Promise들 대기 + 동기 핸들러를 위한 최소 대기
|
||||
if (beforeSaveEventDetail.pendingPromises.length > 0) {
|
||||
console.log(
|
||||
`[handleSave] 비동기 beforeFormSave 핸들러 ${beforeSaveEventDetail.pendingPromises.length}건 대기 중...`,
|
||||
);
|
||||
await Promise.all(beforeSaveEventDetail.pendingPromises);
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// 검증 실패 시 저장 중단
|
||||
if (beforeSaveEventDetail.validationFailed) {
|
||||
console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||
if (onSave) {
|
||||
try {
|
||||
await onSave();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
|
||||
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
|
||||
|
|
@ -1893,7 +1875,11 @@ export class ButtonActionExecutor {
|
|||
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 fallbackTimeout = setTimeout(resolve, 5000);
|
||||
const handler = () => {
|
||||
|
|
@ -1916,6 +1902,7 @@ export class ButtonActionExecutor {
|
|||
);
|
||||
|
||||
await repeaterSavePromise;
|
||||
}
|
||||
|
||||
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
|
||||
context.onRefresh?.();
|
||||
|
|
@ -1951,6 +1938,10 @@ export class ButtonActionExecutor {
|
|||
formDataKeys: Object.keys(formData),
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
|
||||
|
||||
if (hasActiveRepeaters) {
|
||||
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||
const handler = () => {
|
||||
|
|
@ -1973,7 +1964,7 @@ export class ButtonActionExecutor {
|
|||
);
|
||||
|
||||
await repeaterSavePromise;
|
||||
console.log("✅ [dispatchRepeaterSave] repeaterSave 완료");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ const nextConfig = {
|
|||
// Docker 환경: 컨테이너 이름(pms-backend-mac) 또는 SERVER_API_URL 사용
|
||||
// 로컬 개발: http://127.0.0.1:8080 사용
|
||||
async rewrites() {
|
||||
const backendUrl = process.env.SERVER_API_URL || "http://127.0.0.1:8080";
|
||||
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
||||
const backendUrl = process.env.SERVER_API_URL || "http://localhost:8080";
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
|
|
@ -48,7 +49,8 @@ const nextConfig = {
|
|||
|
||||
// 환경 변수 (런타임에 읽기)
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8080/api",
|
||||
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -153,10 +153,12 @@ export interface CommonStyle {
|
|||
// 라벨 스타일
|
||||
labelDisplay?: boolean; // 라벨 표시 여부
|
||||
labelText?: string; // 라벨 텍스트
|
||||
labelPosition?: "top" | "left" | "right" | "bottom"; // 라벨 위치 (기본: top)
|
||||
labelFontSize?: string;
|
||||
labelColor?: string;
|
||||
labelFontWeight?: string;
|
||||
labelMarginBottom?: string;
|
||||
labelGap?: string; // 라벨-위젯 간격 (좌/우 배치 시 사용)
|
||||
|
||||
// 레이아웃
|
||||
display?: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue