From 7a97603106055f377f976384b850eaf7903c61bf Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 25 Feb 2026 17:03:47 +0900 Subject: [PATCH 01/25] =?UTF-8?q?feat(pop-card-list):=203=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EB=B6=84=EB=A6=AC=20+=20=ED=8F=AC=EC=9E=A5=202?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EA=B3=84=EC=82=B0=EA=B8=B0=20+=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 입력 필드/포장등록/담기 버튼 독립 ON/OFF 분리 - NumberInputModal을 4단계 상태 머신으로 재작성 (수량 -> 포장 수 -> 개당 수량 -> summary) - 포장 단위 커스텀 지원 (기본 6종 + 디자이너 추가) - 본문 필드에 계산식 통합 (3-드롭다운 수식 빌더) - 입력 필드: limitColumn(동적 상한), saveTable/saveColumn(저장 대상) - 저장 대상 테이블 선택을 TableCombobox로 교체 (검색 가능) - 다중 정렬 지원 + 하위 호환 (sorts.map 에러 수정) - GroupedColumnSelect 항상 테이블명 헤더 표시 - 반응형 표시 우선순위 (required/shrink/hidden) 설정 - PackageEntry/CartItem 타입 확장, CardPackageConfig 신규 Co-authored-by: Cursor --- .gitignore | 3 +- PLAN.MD | 733 +++---- STATUS.md | 6 +- .../designer/panels/ComponentEditorPanel.tsx | 1 + .../pop-card-list/NumberInputModal.tsx | 473 +++-- .../pop-card-list/PackageUnitModal.tsx | 34 +- .../pop-card-list/PopCardListComponent.tsx | 472 +++-- .../pop-card-list/PopCardListConfig.tsx | 1795 +++++++++++------ .../pop-components/pop-card-list/index.tsx | 9 + frontend/lib/registry/pop-components/types.ts | 116 +- 10 files changed, 2173 insertions(+), 1469 deletions(-) diff --git a/.gitignore b/.gitignore index 0194c053..18a191ed 100644 --- a/.gitignore +++ b/.gitignore @@ -292,4 +292,5 @@ uploads/ claude.md # 개인 작업 문서 (popdocs) -popdocs/ \ No newline at end of file +popdocs/ +.cursor/rules/popdocs-safety.mdc \ No newline at end of file diff --git a/PLAN.MD b/PLAN.MD index 45468fa4..49d2d7e4 100644 --- a/PLAN.MD +++ b/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 && ( -
- - - - - - - - - - - 컬럼을 찾을 수 없습니다. - - - {columns.map((col) => ( - { - 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" - > - - {col.name} - ({col.type}) - - ))} - - - - - -

- 차트에서 X축 카테고리로 사용됩니다 -

-
-)} -``` - -**필요한 state 추가** (DataSourceEditor 내부, 기존 state 옆): - -```tsx -const [groupByOpen, setGroupByOpen] = useState(false); -``` - -#### 변경 A-2: 차트 타입 설정에 xAxisColumn/yAxisColumn 추가 (라인 1187~1212 부근) - -**추가할 위치**: `{item.subType === "chart" && (` 블록 내부, 차트 유형 Select 다음 - -**추가할 코드** (약 30줄): - -```tsx -{/* X축 컬럼 */} -
- - - onUpdate({ - ...item, - chartConfig: { - ...item.chartConfig, - chartType: item.chartConfig?.chartType ?? "bar", - xAxisColumn: e.target.value || undefined, - }, - }) - } - placeholder="groupBy 컬럼명 (비우면 자동)" - className="h-8 text-xs" - /> -

- 그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용 -

-
-``` - -#### 변경 A-3: 통계 카드 타입에 카테고리 설정 UI 추가 (라인 1272 부근, 게이지 설정 다음) - -**추가할 위치**: `{item.subType === "gauge" && (` 블록 다음에 새 블록 추가 - -**추가할 코드** (약 100줄): `StatCategoryEditor` 인라인 블록 - -```tsx -{item.subType === "stat-card" && ( -
-
- - -
- - {(item.statConfig?.categories ?? []).map((cat, catIdx) => ( -
-
- { - 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" - /> - { - 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" - /> - -
- {/* 필터 조건: 컬럼 / 연산자 / 값 */} -
- { - 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]" - /> - - { - 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]" - /> -
-
- ))} - - {(item.statConfig?.categories ?? []).length === 0 && ( -

- 카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다 -

- )} -
-)} -``` - ---- - -### 파일 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 ( - - ); -``` - -**변경 코드**: -```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 ( - - ); -} -``` - -#### 변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297) - -**현재 코드** (버그): -```tsx -case "stat-card": { - const categoryData: Record = {}; - if (item.statConfig?.categories) { - for (const cat of item.statConfig.categories) { - categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값 - } - } - return ( - - ); +**현재 코드** (라인 367~372): +```typescript +export interface CardFieldBinding { + id: string; + columnName: string; + label: string; + textColor?: string; } ``` **변경 코드**: -```tsx -case "stat-card": { - const categoryData: Record = {}; - 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 ( - - ); +```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. 키패드 모달에서 포장등록 버튼이 숨겨지는지 확인 --- ## 이전 완료 계획 (아카이브) +
+pop-dashboard 4가지 아이템 모드 완성 (완료) + +- [x] groupBy UI 추가 +- [x] xAxisColumn 입력 UI 추가 +- [x] 통계카드 카테고리 설정 UI 추가 +- [x] 차트 xAxisColumn 자동 보정 로직 +- [x] 통계카드 카테고리별 필터 적용 +- [x] SQL 빌더 방어 로직 +- [x] refreshInterval 최소값 강제 + +
+
POP 뷰어 스크롤 수정 (완료) -- [x] 라인 185: overflow-hidden 제거 -- [x] 라인 266: overflow-auto 공통 적용 -- [x] 라인 275: 일반 모드 min-h-full 추가 -- [x] 린트 검사 통과 +- [x] overflow-hidden 제거 +- [x] overflow-auto 공통 적용 +- [x] 일반 모드 min-h-full 추가
@@ -521,28 +333,5 @@ ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 ` - [x] 뷰어 페이지에 레지스트리 초기화 import 추가 - [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체 -- [x] 린트 검사 통과 - - - -
-V2/V2 컴포넌트 설정 스키마 정비 (완료) - -- [x] 레거시 컴포넌트 스키마 제거 -- [x] V2 컴포넌트 overrides 스키마 정의 (16개) -- [x] V2 컴포넌트 overrides 스키마 정의 (9개) -- [x] componentConfig.ts 한 파일에서 통합 관리 - -
- -
-화면 복제 기능 개선 (진행 중) - -- [완료] DB 구조 개편 (menu_objid 의존성 제거) -- [완료] 복제 옵션 정리 -- [완료] 화면 간 연결 복제 버그 수정 -- [대기] 화면 간 연결 복제 테스트 -- [대기] 제어관리 복제 테스트 -- [대기] 추가 옵션 복제 테스트
diff --git a/STATUS.md b/STATUS.md index 3aa75278..09b8da12 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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 반응형 표시 런타임 적용 | [ ] 대기 | --- diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 8a5fa621..f605b513 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -515,3 +515,4 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { ); } + diff --git a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx index 806dedb1..209234ec 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx @@ -1,7 +1,7 @@ "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, @@ -11,8 +11,14 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; 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 +28,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 +42,184 @@ export function NumberInputModal({ initialPackageUnit, min = 0, maxValue = 999999, + showPackageUnit, + packageConfig, onConfirm, }: NumberInputModalProps) { const [displayValue, setDisplayValue] = useState(""); - const [packageUnit, setPackageUnit] = useState(undefined); + const [step, setStep] = useState("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([]); + + 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 = 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" + ? remainingQuantity > 0 ? remainingQuantity : maxValue + : 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; - const finalValue = Math.max(min, Math.min(maxValue, numericValue)); - onConfirm(finalValue, packageUnit); + + if (step === "quantity") { + const finalValue = Math.max(min, Math.min(maxValue, numericValue)); + onConfirm(finalValue, undefined, undefined); + onOpenChange(false); + return; + } + + if (step === "package_count") { + if (numericValue <= 0) return; + setPackageCount(numericValue); + setDisplayValue(""); + setStep("quantity_per_unit"); + return; + } + + if (step === "quantity_per_unit") { + if (numericValue <= 0 || !selectedUnit) return; + const total = packageCount * numericValue; + 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 ( <> @@ -86,112 +228,199 @@ export function NumberInputModal({ - {/* 파란 헤더 */} + {/* 헤더 */}
- - 최대 {maxValue.toLocaleString()} {unit} - - +
+ {isBackVisible && ( + + )} + + {headerLabel} + +
+ {isPackageEnabled && step === "quantity" && ( + + )}
- {/* 숫자 표시 영역 */} -
- {displayText ? ( - - {displayText} - - ) : ( - 0 - )} -
+ {/* summary 단계: 포장 내역 리스트 */} + {step === "summary" ? ( +
+ {/* 안내 메시지 - 마지막 등록 결과 */} + {showSummary && entries.length > 0 && ( +
+ {(() => { + const last = entries[entries.length - 1]; + return `${last.packageCount}${last.unitLabel} x ${last.quantityPerUnit}${unit} = ${last.totalQuantity.toLocaleString()}${unit}`; + })()} +
+ )} - {/* 안내 텍스트 */} -

- 수량을 입력하세요 -

+ {/* 포장 내역 리스트 */} +
+

포장 내역

+ {entries.map((entry, idx) => ( +
+ + {entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}{unit} = {entry.totalQuantity.toLocaleString()}{unit} + + +
+ ))} +
- {/* 키패드 4x4 */} -
- {/* 1행: 7 8 9 ← (주황) */} - {["7", "8", "9"].map((n) => ( - - ))} - + {/* 합계 */} +
+ 합계 + + {entriesTotal.toLocaleString()} {unit} + +
- {/* 2행: 4 5 6 C (주황) */} - {["4", "5", "6"].map((n) => ( - - ))} - + {/* 남은 수량 */} + {remainingQuantity > 0 && ( +
+ 남은 수량 + + {remainingQuantity.toLocaleString()} {unit} + +
+ )} - {/* 3행: 1 2 3 MAX (파란) */} - {["1", "2", "3"].map((n) => ( - - ))} - + {/* 액션 버튼 */} +
+ {remainingQuantity > 0 && ( + + )} + +
+
+ ) : ( + <> + {/* 숫자 표시 영역 */} +
+ {displayText ? ( + + {displayText} + + ) : ( + 0 + )} +
- {/* 4행: 0 / 확인 (초록, 3칸) */} - - -
+ {/* 단계별 안내 텍스트 */} +

+ {guideMessage} +

+ + {/* 키패드 4x4 */} +
+ {["7", "8", "9"].map((n) => ( + + ))} + + + {["4", "5", "6"].map((n) => ( + + ))} + + + {["1", "2", "3"].map((n) => ( + + ))} + + + + +
+ + )}
@@ -201,8 +430,18 @@ export function NumberInputModal({ {/* 포장 단위 선택 모달 */} { + setIsPackageModalOpen(isOpen); + if (!isOpen && step === "summary") { + // summary에서 추가 포장 모달 닫힘 -> 단위 선택 안 한 경우 유지 + } + }} + onSelect={(unitId) => { + handlePackageUnitSelect(unitId); + setIsPackageModalOpen(false); + }} + enabledUnits={packageConfig?.enabledUnits} + customUnits={packageConfig?.customUnits} /> ); diff --git a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx index 0911bc47..ad050744 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx @@ -9,6 +9,7 @@ import { DialogOverlay, DialogClose, } from "@/components/ui/dialog"; +import type { CustomPackageUnit } from "../types"; export const PACKAGE_UNITS = [ { value: "box", label: "박스", emoji: "📦" }, @@ -24,19 +25,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 ( @@ -45,18 +60,16 @@ export function PackageUnitModal({ - {/* 헤더 */}

📦 포장 단위 선택

- {/* 3x2 그리드 */}
- {PACKAGE_UNITS.map((unit) => ( + {allUnits.map((unit) => (
- {/* X 닫기 버튼 */} + {allUnits.length === 0 && ( +
+ 사용 가능한 포장 단위가 없습니다 +
+ )} + Close diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index bf7d71ed..9d21a8d4 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -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, @@ -33,11 +37,13 @@ import { dataApi } from "@/lib/api/data"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { NumberInputModal } from "./NumberInputModal"; -// Lucide 아이콘 동적 렌더링 +const LUCIDE_ICON_MAP: Record = { + ShoppingCart, Package, Truck, Box, Archive, Heart, Star, +}; + function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) { if (!name) return ; - const icons = LucideIcons as unknown as Record>; - const IconComp = icons[name]; + const IconComp = LUCIDE_ICON_MAP[name]; if (!IconComp) return ; return ; } @@ -157,25 +163,58 @@ export function PopCardListComponent({ const dataSource = config?.dataSource; const template = config?.cardTemplate; - // 이벤트 기반 company_code 필터링 - const [eventCompanyCode, setEventCompanyCode] = useState(); const { subscribe, publish, getSharedData, setSharedData } = 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]); - // 데이터 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합) + const [externalFilters, setExternalFilters] = useState< + Map + >(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]); + + // 카드 선택 시 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); @@ -202,7 +241,8 @@ export function PopCardListComponent({ 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(() => { @@ -218,65 +258,80 @@ export function PopCardListComponent({ : maxGridColumns; const gridColumns = 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; - return { - cardHeight, - cardWidth, - imageSize: Math.round(spec.imageSize * scale), - padding: Math.round(spec.padding * scale), - 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), + 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: spec.imageSize, + padding: spec.padding, + gap, + headerPaddingX: spec.headerPadX, + headerPaddingY: spec.headerPadY, + codeTextSize: spec.codeText, + titleTextSize: spec.titleText, + bodyTextSize: spec.bodyText, + }; + }, [spec, containerWidth, gridColumns]); + + // 외부 필터 적용 (복수 필터 AND 결합) + const filteredRows = useMemo(() => { + if (externalFilters.size === 0) return rows; + + const matchSingleFilter = ( + row: RowData, + filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } } + ): boolean => { + const searchValue = String(filter.value).toLowerCase(); + if (!searchValue) return true; + + const fc = filter.filterConfig; + const columns: string[] = + fc?.targetColumns?.length + ? fc.targetColumns + : fc?.targetColumn + ? [fc.targetColumn] + : filter.fieldName + ? [filter.fieldName] + : []; + + if (columns.length === 0) return true; + + const mode = fc?.filterMode || "contains"; + + const matchCell = (cellValue: string) => { + switch (mode) { + case "equals": + return cellValue === searchValue; + case "starts_with": + return cellValue.startsWith(searchValue); + case "contains": + default: + return cellValue.includes(searchValue); + } }; + + return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase())); }; - if (containerWidth <= 0 || containerHeight <= 0) { - return buildScaledConfig(Math.round(spec.height * 1.6), spec.height); - } - - const effectiveHeight = baseContainerHeight.current > 0 - ? baseContainerHeight.current - : containerHeight; - - const availableHeight = effectiveHeight - controlBarHeight; - const availableWidth = containerWidth; - - const cardHeight = Math.max(spec.height, - Math.floor((availableHeight - gap * (gridRows - 1)) / gridRows)); - const cardWidth = Math.max(Math.round(spec.height * 1.6), - Math.floor((availableWidth - gap * (gridColumns - 1)) / gridColumns)); - - return buildScaledConfig(cardWidth, cardHeight); - }, [spec, containerWidth, containerHeight, gridColumns, gridRows]); + const allFilters = [...externalFilters.values()]; + return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f))); + }, [rows, externalFilters]); // 기본 상태에서 표시할 카드 수 const visibleCardCount = useMemo(() => { @@ -284,7 +339,7 @@ export function PopCardListComponent({ }, [gridColumns, gridRows]); // 더보기 버튼 표시 여부 - const hasMoreCards = rows.length > visibleCardCount; + const hasMoreCards = filteredRows.length > visibleCardCount; // 확장 상태에서 표시할 카드 수 계산 const expandedCardsPerPage = useMemo(() => { @@ -300,19 +355,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 +411,12 @@ export function PopCardListComponent({ } }, [currentPage, isExpanded]); - // 데이터 조회 + // dataSource를 직렬화해서 의존성 안정화 (객체 참조 변경에 의한 불필요한 재호출 방지) + const dataSourceKey = useMemo( + () => JSON.stringify(dataSource || null), + [dataSource] + ); + useEffect(() => { if (!dataSource?.tableName) { setLoading(false); @@ -373,7 +431,6 @@ export function PopCardListComponent({ toastShownRef.current = false; try { - // 필터 조건 구성 const filters: Record = {}; if (dataSource.filters && dataSource.filters.length > 0) { dataSource.filters.forEach((f) => { @@ -383,28 +440,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,7 +474,7 @@ export function PopCardListComponent({ }; fetchData(); - }, [dataSource, eventCompanyCode]); + }, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps // 이미지 URL 없는 항목 체크 및 toast 표시 useEffect(() => { @@ -503,21 +557,27 @@ export function PopCardListComponent({ justifyContent: isHorizontalMode ? "start" : "center", }} > - {displayCards.map((row, index) => ( + {displayCards.map((row, index) => { + const rowKey = template?.header?.codeField && row[template.header.codeField] + ? String(row[template.header.codeField]) + : `card-${index}`; + return ( - ))} + ); + })} {/* 하단 컨트롤 영역 */} @@ -544,7 +604,7 @@ export function PopCardListComponent({ )} - {rows.length}건 + {filteredRows.length}건{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}건` : ""} @@ -589,69 +649,60 @@ function Card({ template, scaled, inputField, - calculatedField, + packageConfig, cartAction, publish, getSharedData, setSharedData, router, + onSelect, }: { row: RowData; template?: CardTemplateConfig; scaled: ScaledConfig; inputField?: CardInputFieldConfig; - calculatedField?: CardCalculatedFieldConfig; + packageConfig?: CardPackageConfig; cartAction?: CardCartActionConfig; publish: (eventName: string, payload?: unknown) => void; getSharedData: (key: string) => T | undefined; setSharedData: (key: string, value: unknown) => void; router: ReturnType; + onSelect?: (row: RowData) => void; }) { const header = template?.header; const image = template?.image; const body = template?.body; - // 입력 필드 상태 - const [inputValue, setInputValue] = useState( - inputField?.defaultValue || 0 - ); + const [inputValue, setInputValue] = useState(0); const [packageUnit, setPackageUnit] = useState(undefined); + const [packageEntries, setPackageEntries] = useState([]); 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 결정 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,9 +728,10 @@ 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 누적 + 이벤트 발행 + 토글 @@ -688,6 +740,7 @@ function Card({ row, quantity: inputValue, packageUnit: packageUnit || undefined, + packageEntries: packageEntries.length > 0 ? packageEntries : undefined, }; const existing = getSharedData("cart_items") || []; @@ -721,10 +774,18 @@ function Card({ const cartLabel = cartAction?.label || "담기"; const cancelLabel = cartAction?.cancelLabel || "취소"; + const handleCardClick = () => { + onSelect?.(row); + }; + return (
{ if (e.key === "Enter" || e.key === " ") handleCardClick(); }} > {/* 헤더 영역 */} {(codeValue !== null || titleValue !== null) && ( @@ -777,7 +838,7 @@ function Card({
{body?.fields && body.fields.length > 0 ? ( body.fields.map((field) => ( - + )) ) : (
)} - - {/* 계산 필드 */} - {calculatedField?.enabled && calculatedValue !== null && ( -
- - {calculatedField.label || "계산값"} - - - {calculatedValue.toLocaleString()}{calculatedField.unit ? ` ${calculatedField.unit}` : ""} - -
- )}
- {/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (inputField 활성화 시만) */} - {inputField?.enabled && ( + {/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */} + {(inputField?.enabled || cartAction) && (
- {/* 수량 버튼 */} - + {/* 수량 버튼 (입력 필드 ON일 때만) */} + {inputField?.enabled && ( + + )} - {/* pop-icon 스타일 담기/취소 토글 버튼 */} - {isCarted ? ( - - ) : ( - ) : ( - + )} - - {cartLabel} - - + )}
)}
- {/* 숫자 입력 모달 */} {inputField?.enabled && ( )} @@ -883,14 +932,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 +987,17 @@ function FieldRow({ className="flex items-baseline" style={{ gap: `${Math.round(scaled.gap / 2)}px`, fontSize: `${scaled.bodyTextSize}px` }} > - {/* 라벨 */} {field.label} - {/* 값 - 기본 검정색, textColor 설정 시 해당 색상 */} - {formatValue(value)} + {displayValue} ); @@ -982,7 +1069,6 @@ function evaluateFormula( // 안전한 계산 (기본 산술 연산만 허용) // 허용: 숫자, +, -, *, /, (, ), 공백, 소수점 if (!/^[\d\s+\-*/().]+$/.test(expression)) { - console.warn("Invalid formula expression:", expression); return null; } @@ -995,7 +1081,7 @@ function evaluateFormula( return Math.round(result * 100) / 100; // 소수점 2자리까지 } catch (error) { - console.warn("Formula evaluation error:", error); + // 수식 평가 실패 시 null 반환 return null; } } diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index d8b31c34..418b4ddf 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -3,14 +3,13 @@ /** * pop-card-list 설정 패널 * - * 3개 탭: - * [테이블] - 데이터 테이블 선택 - * [카드 템플릿] - 헤더/이미지/본문 필드 + 레이아웃 설정 - * [데이터 소스] - 조인/필터/정렬/개수 설정 + * 2개 탭: + * [기본 설정] - 테이블 선택 + 조인/정렬 + 레이아웃 설정 + * [카드 템플릿] - 헤더/이미지/본문/입력/계산/담기 설정 */ import React, { useState, useEffect, useMemo } from "react"; -import { ChevronDown, ChevronRight, Plus, Trash2, Database } from "lucide-react"; +import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check, ChevronsUpDown } from "lucide-react"; import type { GridMode } from "@/components/pop/designer/types/pop-layout"; import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout"; import { Button } from "@/components/ui/button"; @@ -20,28 +19,40 @@ import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { cn } from "@/lib/utils"; import type { PopCardListConfig, CardListDataSource, + CardSortConfig, CardTemplateConfig, CardHeaderConfig, CardImageConfig, CardBodyConfig, CardFieldBinding, + FieldValueType, + FormulaOperator, + FormulaRightType, CardColumnJoin, CardColumnFilter, CardScrollDirection, FilterOperator, CardInputFieldConfig, - CardCalculatedFieldConfig, + CardPackageConfig, CardCartActionConfig, + CardResponsiveConfig, + ResponsiveDisplayMode, } from "../types"; import { CARD_SCROLL_DIRECTION_LABELS, + RESPONSIVE_DISPLAY_LABELS, DEFAULT_CARD_IMAGE, } from "../types"; import { @@ -51,6 +62,14 @@ import { type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; +// ===== 테이블별 그룹화된 컬럼 ===== + +interface ColumnGroup { + tableName: string; + displayName: string; + columns: ColumnInfo[]; +} + // ===== Props ===== interface ConfigPanelProps { @@ -96,7 +115,7 @@ const DEFAULT_CONFIG: PopCardListConfig = { cardSize: "large", }; -// ===== 색상 옵션 ===== +// ===== 색상 옵션 (본문 필드 텍스트 색상) ===== const COLOR_OPTIONS = [ { value: "__default__", label: "기본" }, @@ -112,25 +131,19 @@ const COLOR_OPTIONS = [ // ===== 메인 컴포넌트 ===== export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) { - // 3탭 구조: 기본 설정 (테이블+레이아웃) → 데이터 소스 → 카드 템플릿 - const [activeTab, setActiveTab] = useState<"basic" | "template" | "dataSource">( - "basic" - ); + const [activeTab, setActiveTab] = useState<"basic" | "template">("basic"); - // config가 없으면 기본값 사용 const cfg: PopCardListConfig = config || DEFAULT_CONFIG; - // config 업데이트 헬퍼 const updateConfig = (partial: Partial) => { onUpdate({ ...cfg, ...partial }); }; - // 테이블이 선택되었는지 확인 const hasTable = !!cfg.dataSource?.tableName; return (
- {/* 탭 헤더 - 3탭 구조 */} + {/* 탭 헤더 - 2탭 */}
- + + + + + + + 검색 결과가 없습니다. + + + {filtered.map((table) => ( + { + onSelect(table.tableName); + setOpen(false); + setSearch(""); + }} + className="text-xs" + > + +
+ {table.displayName || table.tableName} + {table.displayName && ( + + {table.tableName} + + )} +
+
+ ))} +
+
+
+
+ + ); +} + +// ===== 테이블별 그룹화된 컬럼 셀렉트 ===== + +function GroupedColumnSelect({ + columnGroups, + value, + onValueChange, + placeholder = "컬럼 선택", + allowNone = false, + noneLabel = "선택 안함", + className, +}: { + columnGroups: ColumnGroup[]; + value: string | undefined; + onValueChange: (value: string | undefined) => void; + placeholder?: string; + allowNone?: boolean; + noneLabel?: string; + className?: string; +}) { + return ( + + ); +} + // ===== 접기/펴기 섹션 컴포넌트 ===== function CollapsibleSection({ @@ -638,11 +840,11 @@ function CollapsibleSection({ function HeaderSettingsSection({ header, - columns, + columnGroups, onUpdate, }: { header: CardHeaderConfig; - columns: ColumnInfo[]; + columnGroups: ColumnGroup[]; onUpdate: (header: CardHeaderConfig) => void; }) { return ( @@ -650,24 +852,15 @@ function HeaderSettingsSection({ {/* 코드 필드 */}
- + onUpdate({ ...header, codeField: val })} + placeholder="컬럼 선택 (선택사항)" + allowNone + noneLabel="선택 안함" + className="mt-1" + />

카드 헤더 왼쪽에 표시될 코드 (예: ITEM032)

@@ -676,24 +869,15 @@ function HeaderSettingsSection({ {/* 제목 필드 */}
- + onUpdate({ ...header, titleField: val })} + placeholder="컬럼 선택 (선택사항)" + allowNone + noneLabel="선택 안함" + className="mt-1" + />

카드 헤더 오른쪽에 표시될 제목 (예: 너트 M10)

@@ -706,11 +890,11 @@ function HeaderSettingsSection({ function ImageSettingsSection({ image, - columns, + columnGroups, onUpdate, }: { image: CardImageConfig; - columns: ColumnInfo[]; + columnGroups: ColumnGroup[]; onUpdate: (image: CardImageConfig) => void; }) { return ( @@ -764,24 +948,15 @@ function ImageSettingsSection({ {/* 이미지 컬럼 선택 */}
- + onUpdate({ ...image, imageColumn: val })} + placeholder="컬럼 선택 (선택사항)" + allowNone + noneLabel="선택 안함 (기본 이미지 사용)" + className="mt-1" + />

DB에서 이미지 URL을 가져올 컬럼. URL이 없으면 기본 이미지 사용

@@ -796,22 +971,21 @@ function ImageSettingsSection({ function BodyFieldsSection({ body, - columns, + columnGroups, onUpdate, }: { body: CardBodyConfig; - columns: ColumnInfo[]; + columnGroups: ColumnGroup[]; onUpdate: (body: CardBodyConfig) => void; }) { const fields = body.fields || []; - // 필드 추가 const addField = () => { const newField: CardFieldBinding = { id: `field-${Date.now()}`, - columnName: "", label: "", - textColor: undefined, + valueType: "column", + columnName: "", }; onUpdate({ fields: [...fields, newField] }); }; @@ -858,7 +1032,7 @@ function BodyFieldsSection({ key={field.id} field={field} index={index} - columns={columns} + columnGroups={columnGroups} totalCount={fields.length} onUpdate={(updated) => updateField(index, updated)} onDelete={() => deleteField(index)} @@ -884,10 +1058,27 @@ function BodyFieldsSection({ // ===== 필드 편집기 ===== +const VALUE_TYPE_OPTIONS: { value: FieldValueType; label: string }[] = [ + { value: "column", label: "DB 컬럼" }, + { value: "formula", label: "계산식" }, +]; + +const FORMULA_OPERATOR_OPTIONS: { value: FormulaOperator; label: string }[] = [ + { value: "+", label: "+ (더하기)" }, + { value: "-", label: "- (빼기)" }, + { value: "*", label: "* (곱하기)" }, + { value: "/", label: "/ (나누기)" }, +]; + +const FORMULA_RIGHT_TYPE_OPTIONS: { value: FormulaRightType; label: string }[] = [ + { value: "column", label: "DB 컬럼" }, + { value: "input", label: "입력값" }, +]; + function FieldEditor({ field, index, - columns, + columnGroups, totalCount, onUpdate, onDelete, @@ -895,12 +1086,15 @@ function FieldEditor({ }: { field: CardFieldBinding; index: number; - columns: ColumnInfo[]; + columnGroups: ColumnGroup[]; totalCount: number; onUpdate: (field: CardFieldBinding) => void; onDelete: () => void; onMove: (direction: "up" | "down") => void; }) { + const valueType = field.valueType || "column"; + const rightType = field.formulaRightType || "input"; + return (
@@ -926,37 +1120,43 @@ function FieldEditor({ {/* 필드 설정 */}
+ {/* 1행: 라벨 + 값 유형 */}
- {/* 라벨 */}
onUpdate({ ...field, label: e.target.value })} - placeholder="예: 발주일" + placeholder="예: 미입고" className="mt-1 h-7 text-xs" />
- - {/* 컬럼 */} -
- +
+ + onUpdate({ ...field, formulaOperator: val === "__none__" ? undefined : val as FormulaOperator }) + } + > + + + + + 선택 + {FORMULA_OPERATOR_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + +
+ + {/* 오른쪽 값 유형 + 값 */} +
+ + + + {rightType === "column" && ( + onUpdate({ ...field, formulaRight: val || undefined })} + placeholder="컬럼 선택" + /> + )} + + {rightType === "input" && ( +

+ 카드의 숫자 입력 필드 값이 사용됩니다 +

+ )} +
+ + {/* 단위 */} +
+ + onUpdate({ ...field, unit: e.target.value })} + className="mt-1 h-7 text-xs" + placeholder="EA" + /> +
+ + {/* 수식 미리보기 */} + {field.formulaLeft && field.formulaOperator && ( +
+

수식 미리보기

+

+ {field.formulaLeft} {field.formulaOperator} {rightType === "input" ? "$input" : (field.formulaRight || "?")} +

+
+ )} + + )} + {/* 텍스트 색상 */}
@@ -1015,26 +1328,36 @@ function FieldEditor({ function InputFieldSettingsSection({ inputField, columns, + tables, onUpdate, }: { inputField?: CardInputFieldConfig; columns: ColumnInfo[]; + tables: TableInfo[]; onUpdate: (inputField: CardInputFieldConfig) => void; }) { const field = inputField || { enabled: false, - label: "발주 수량", unit: "EA", - defaultValue: 0, - min: 0, - max: 999999, - step: 1, }; + // 하위 호환: maxColumn -> limitColumn 마이그레이션 + const effectiveLimitColumn = field.limitColumn || field.maxColumn; + const updateField = (partial: Partial) => { onUpdate({ ...field, ...partial }); }; + // 저장 테이블 컬럼 로드 + const [saveTableColumns, setSaveTableColumns] = useState([]); + useEffect(() => { + if (field.saveTable) { + fetchTableColumns(field.saveTable).then(setSaveTableColumns); + } else { + setSaveTableColumns([]); + } + }, [field.saveTable]); + return (
{/* 활성화 스위치 */} @@ -1048,17 +1371,6 @@ function InputFieldSettingsSection({ {field.enabled && ( <> - {/* 라벨 */} -
- - updateField({ label: e.target.value })} - className="mt-1 h-7 text-xs" - placeholder="발주 수량" - /> -
- {/* 단위 */}
@@ -1070,86 +1382,22 @@ function InputFieldSettingsSection({ />
- {/* 기본값 */} -
- - updateField({ defaultValue: parseInt(e.target.value, 10) || 0 })} - className="mt-1 h-7 text-xs" - placeholder="0" - /> -
- - {/* 최소/최대값 */} -
-
- - updateField({ min: parseInt(e.target.value, 10) || 0 })} - className="mt-1 h-7 text-xs" - placeholder="0" - /> -
-
- - updateField({ max: parseInt(e.target.value, 10) || 999999 })} - className="mt-1 h-7 text-xs" - placeholder="999999" - /> -
-
- - {/* 최대값 컬럼 */} + {/* 제한 기준 컬럼 */}
-

- 설정 시 각 카드 행의 해당 컬럼 값이 숫자패드 최대값으로 사용됨 (예: unreceived_qty) -

-
- - {/* 저장 컬럼 (선택사항) */} -
- -

- 입력값을 저장할 DB 컬럼 (현재는 로컬 상태만 유지) + 각 카드 행의 해당 컬럼 값이 숫자패드 최대값 (예: order_qty)

+ + {/* 저장 대상 테이블 (검색 가능) */} +
+ + + updateField({ + saveTable: tableName || undefined, + saveColumn: undefined, + }) + } + /> +
+ + {/* 저장 대상 컬럼 */} + {field.saveTable && ( +
+ + +

+ 입력값이 저장될 DB 컬럼 +

+
+ )} + )}
); } -// ===== 계산 필드 설정 섹션 ===== +// ===== 포장등록 설정 섹션 ===== -function CalculatedFieldSettingsSection({ - calculatedField, - columns, +import { PACKAGE_UNITS } from "./PackageUnitModal"; + +function PackageSettingsSection({ + packageConfig, onUpdate, }: { - calculatedField?: CardCalculatedFieldConfig; - columns: ColumnInfo[]; - onUpdate: (calculatedField: CardCalculatedFieldConfig) => void; + packageConfig?: CardPackageConfig; + onUpdate: (config: CardPackageConfig) => void; }) { - const field = calculatedField || { + const config: CardPackageConfig = packageConfig || { enabled: false, - label: "미입고", - formula: "", - sourceColumns: [], - unit: "EA", }; - const updateField = (partial: Partial) => { - onUpdate({ ...field, ...partial }); + const updateConfig = (partial: Partial) => { + onUpdate({ ...config, ...partial }); + }; + + const enabledSet = new Set(config.enabledUnits ?? PACKAGE_UNITS.map((u) => u.value)); + + const toggleUnit = (value: string) => { + const next = new Set(enabledSet); + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + updateConfig({ enabledUnits: Array.from(next) }); + }; + + const addCustomUnit = () => { + const existing = config.customUnits || []; + const id = `custom_${Date.now()}`; + updateConfig({ + customUnits: [...existing, { id, label: "" }], + }); + }; + + const updateCustomUnit = (id: string, label: string) => { + const existing = config.customUnits || []; + updateConfig({ + customUnits: existing.map((cu) => (cu.id === id ? { ...cu, label } : cu)), + }); + }; + + const removeCustomUnit = (id: string) => { + const existing = config.customUnits || []; + updateConfig({ + customUnits: existing.filter((cu) => cu.id !== id), + }); }; return (
- {/* 활성화 스위치 */}
- + updateField({ enabled })} + checked={config.enabled} + onCheckedChange={(enabled) => updateConfig({ enabled })} />
- {field.enabled && ( + {config.enabled && ( <> - {/* 라벨 */} + {/* 기본 포장 단위 체크박스 */}
- - updateField({ label: e.target.value })} - className="mt-1 h-7 text-xs" - placeholder="미입고" - /> -
- - {/* 계산식 */} -
- - updateField({ formula: e.target.value })} - className="mt-1 h-7 text-xs font-mono" - placeholder="$input - received_qty" - /> -

- 사용 가능: 컬럼명, $input (입력값), +, -, *, / -

-
- - {/* 단위 */} -
- - updateField({ unit: e.target.value })} - className="mt-1 h-7 text-xs" - placeholder="EA" - /> -
- - {/* 사용 가능한 컬럼 목록 */} -
- -
-
- {columns.map((col) => ( - { - // 클릭 시 계산식에 컬럼명 추가 - const currentFormula = field.formula || ""; - updateField({ formula: currentFormula + col.name }); - }} - > - {col.name} - - ))} -
+ +
+ {PACKAGE_UNITS.map((unit) => ( + + ))}
-

- 클릭하면 계산식에 추가됩니다 -

+ + {/* 커스텀 단위 */} +
+ +
+ {(config.customUnits || []).map((cu) => ( +
+ updateCustomUnit(cu.id, e.target.value)} + className="h-7 flex-1 text-xs" + placeholder="단위 이름 (예: 파렛트)" + /> + +
+ ))} +
+ +
+ + {/* 계산 결과 안내 메시지 */} +
+ + updateConfig({ showSummaryMessage: checked })} + /> +
+

+ 포장 등록 시 계산 결과를 안내 메시지로 표시합니다 +

)}
); } -// ===== 조인 설정 섹션 ===== +// ===== 조인 설정 섹션 (테이블 선택 -> 컬럼 자동 매칭) ===== + +// 두 테이블 간 매칭 가능한 컬럼 쌍 찾기 +function findMatchingColumns( + sourceCols: ColumnInfo[], + targetCols: ColumnInfo[], +): Array<{ source: string; target: string; confidence: "high" | "medium" }> { + const matches: Array<{ source: string; target: string; confidence: "high" | "medium" }> = []; + const sourceNames = sourceCols.map((c) => c.name); + const targetNames = targetCols.map((c) => c.name); + + for (const src of sourceNames) { + for (const tgt of targetNames) { + // 정확히 같은 이름 (예: item_code = item_code) + if (src === tgt) { + matches.push({ source: src, target: tgt, confidence: "high" }); + continue; + } + // 소스가 _id/_code/_no 로 끝나고, 타겟 테이블에 같은 이름이 있는 경우 + // 예: source.customer_code -> target.customer_code (이미 위에서 처리됨) + // 소스 컬럼명이 타겟 컬럼명을 포함하거나, 타겟이 소스를 포함 + const suffixes = ["_id", "_code", "_no", "_number", "_key"]; + const srcBase = suffixes.reduce((name, s) => name.endsWith(s) ? name.slice(0, -s.length) : name, src); + const tgtBase = suffixes.reduce((name, s) => name.endsWith(s) ? name.slice(0, -s.length) : name, tgt); + if (srcBase && tgtBase && srcBase === tgtBase && src !== tgt) { + matches.push({ source: src, target: tgt, confidence: "medium" }); + } + } + } + + // confidence 높은 순 정렬 + return matches.sort((a, b) => (a.confidence === "high" ? -1 : 1) - (b.confidence === "high" ? -1 : 1)); +} function JoinSettingsSection({ dataSource, @@ -1284,175 +1647,185 @@ function JoinSettingsSection({ }) { const joins = dataSource.joins || []; const [sourceColumns, setSourceColumns] = useState([]); - const [targetColumnsMap, setTargetColumnsMap] = useState< - Record - >({}); + const [targetColumnsMap, setTargetColumnsMap] = useState>({}); - // 소스 테이블 컬럼 로드 useEffect(() => { if (dataSource.tableName) { fetchTableColumns(dataSource.tableName).then(setSourceColumns); + } else { + setSourceColumns([]); } }, [dataSource.tableName]); - // 조인 추가 - const addJoin = () => { - const newJoin: CardColumnJoin = { - targetTable: "", - joinType: "LEFT", - sourceColumn: "", - targetColumn: "", - }; - onUpdate({ joins: [...joins, newJoin] }); + const getTableLabel = (tableName: string) => { + const found = tables.find((t) => t.tableName === tableName); + return found?.displayName || tableName; }; - // 조인 업데이트 - const updateJoin = (index: number, updated: CardColumnJoin) => { - const newJoins = [...joins]; - newJoins[index] = updated; - onUpdate({ joins: newJoins }); + // 대상 테이블 컬럼 로드 + 자동 매칭 + const loadTargetAndAutoMatch = (index: number, join: CardColumnJoin, targetTable: string) => { + const updated = { ...join, targetTable, sourceColumn: "", targetColumn: "" }; - // 대상 테이블 컬럼 로드 - if (updated.targetTable && !targetColumnsMap[updated.targetTable]) { - fetchTableColumns(updated.targetTable).then((cols) => { - setTargetColumnsMap((prev) => ({ - ...prev, - [updated.targetTable]: cols, - })); + const doMatch = (targetCols: ColumnInfo[]) => { + const matches = findMatchingColumns(sourceColumns, targetCols); + if (matches.length > 0) { + updated.sourceColumn = matches[0].source; + updated.targetColumn = matches[0].target; + } + const newJoins = [...joins]; + newJoins[index] = updated; + onUpdate({ joins: newJoins }); + }; + + if (targetColumnsMap[targetTable]) { + doMatch(targetColumnsMap[targetTable]); + } else { + fetchTableColumns(targetTable).then((cols) => { + setTargetColumnsMap((prev) => ({ ...prev, [targetTable]: cols })); + doMatch(cols); }); } }; - // 조인 삭제 + const updateJoin = (index: number, updated: CardColumnJoin) => { + const newJoins = [...joins]; + newJoins[index] = updated; + onUpdate({ joins: newJoins }); + }; + const deleteJoin = (index: number) => { const newJoins = joins.filter((_, i) => i !== index); onUpdate({ joins: newJoins.length > 0 ? newJoins : undefined }); }; + const addJoin = () => { + onUpdate({ joins: [...joins, { targetTable: "", joinType: "LEFT", sourceColumn: "", targetColumn: "" }] }); + }; + return (
{joins.length === 0 ? (

- 다른 테이블과 조인하여 추가 컬럼을 사용할 수 있습니다 + 다른 테이블을 연결하면 추가 정보를 카드에 표시할 수 있습니다

) : (
- {joins.map((join, index) => ( -
-
- - 조인 {index + 1} - - -
+ {joins.map((join, index) => { + const targetCols = targetColumnsMap[join.targetTable] || []; + const matchingPairs = join.targetTable + ? findMatchingColumns(sourceColumns, targetCols) + : []; + const hasAutoMatch = join.sourceColumn && join.targetColumn; - {/* 조인 타입 */} - - - {/* 대상 테이블 */} - - - {/* ON 조건 */} - {join.targetTable && ( -
- - = - + return ( +
+
+ + {join.targetTable ? getTableLabel(join.targetTable) : "테이블 선택"} + +
- )} -
- ))} + + {/* 대상 테이블 선택 (검색 가능) */} + t.tableName !== dataSource.tableName)} + value={join.targetTable || ""} + onSelect={(val) => loadTargetAndAutoMatch(index, join, val)} + /> + + {/* 자동 매칭 결과 또는 수동 선택 */} + {join.targetTable && ( +
+ {/* 자동 매칭 성공: 결과 표시 */} + {hasAutoMatch ? ( +
+ + + 자동 연결됨: {join.sourceColumn} = {join.targetColumn} + +
+ ) : ( +
+ + 자동 매칭되는 컬럼이 없습니다. 직접 선택하세요. + +
+ )} + + {/* 다른 매칭 후보가 있으면 표시 */} + {matchingPairs.length > 1 && ( +
+ 다른 연결 기준: + {matchingPairs.map((pair) => { + const isActive = join.sourceColumn === pair.source && join.targetColumn === pair.target; + if (isActive) return null; + return ( + + ); + })} +
+ )} + + {/* 수동 선택 (펼치기) */} +
+ + 직접 컬럼 선택 + +
+ + = + +
+
+
+ )} +
+ ); + })}
)} -
); @@ -1593,101 +1966,108 @@ function FilterSettingsSection({ ); } -// ===== 정렬 설정 섹션 ===== +// ===== 정렬 설정 섹션 (다중 정렬) ===== function SortSettingsSection({ dataSource, - columns, + columnGroups, onUpdate, }: { dataSource: CardListDataSource; - columns: ColumnInfo[]; + columnGroups: ColumnGroup[]; onUpdate: (partial: Partial) => void; }) { - const sort = dataSource.sort; + // 하위 호환: 이전 형식(단일 객체)이 저장되어 있을 수 있음 + const sorts: CardSortConfig[] = Array.isArray(dataSource.sort) + ? dataSource.sort + : dataSource.sort && typeof dataSource.sort === "object" + ? [dataSource.sort as CardSortConfig] + : []; + + const addSort = () => { + onUpdate({ sort: [...sorts, { column: "", direction: "desc" }] }); + }; + + const updateSort = (index: number, updated: CardSortConfig) => { + const newSorts = [...sorts]; + newSorts[index] = updated; + onUpdate({ sort: newSorts }); + }; + + const deleteSort = (index: number) => { + const newSorts = sorts.filter((_, i) => i !== index); + onUpdate({ sort: newSorts.length > 0 ? newSorts : undefined }); + }; return (
- {/* 정렬 사용 여부 */} -
- - -
+

+ 화면 로드 시 적용되는 기본 정렬 순서입니다. 위에 있는 항목이 우선 적용됩니다. +

- {sort && ( + {sorts.length === 0 ? ( +
+

정렬 기준이 없습니다

+
+ ) : (
- {/* 정렬 컬럼 */} -
- - -
- - {/* 정렬 방향 */} -
- -
+ {sorts.map((sort, index) => ( +
+ + {index + 1} + +
+ updateSort(index, { ...sort, column: val || "" })} + placeholder="컬럼 선택" + /> +
+
-
+ ))}
)} + +
); } @@ -1872,3 +2252,142 @@ function CartActionSettingsSection({
); } + +// ===== 반응형 표시 설정 섹션 ===== + +const RESPONSIVE_MODES: ResponsiveDisplayMode[] = ["required", "shrink", "hidden"]; + +function ResponsiveDisplaySection({ + config, + onUpdate, +}: { + config: PopCardListConfig; + onUpdate: (partial: Partial) => void; +}) { + const template = config.cardTemplate || { header: {}, image: { enabled: false }, body: { fields: [] } }; + const responsive = config.responsiveDisplay || {}; + const bodyFields = template.body?.fields || []; + + const hasHeader = !!template.header?.codeField || !!template.header?.titleField; + const hasImage = !!template.image?.enabled; + const hasFields = bodyFields.length > 0; + + const updateResponsive = (partial: Partial) => { + onUpdate({ responsiveDisplay: { ...responsive, ...partial } }); + }; + + const updateFieldMode = (fieldId: string, mode: ResponsiveDisplayMode) => { + updateResponsive({ + fields: { ...(responsive.fields || {}), [fieldId]: mode }, + }); + }; + + if (!hasHeader && !hasImage && !hasFields) { + return ( +
+

+ 카드 템플릿에 항목을 먼저 추가하세요 +

+
+ ); + } + + return ( +
+

+ 화면이 좁아질 때 각 항목이 어떻게 표시될지 설정합니다. +

+ +
+ {RESPONSIVE_MODES.map((mode) => ( + + {RESPONSIVE_DISPLAY_LABELS[mode]} + {mode === "required" && " = 항상 표시"} + {mode === "shrink" && " = 축소 가능"} + {mode === "hidden" && " = 숨김 가능"} + + ))} +
+ +
+ {template.header?.codeField && ( + updateResponsive({ code: mode })} + /> + )} + {template.header?.titleField && ( + updateResponsive({ title: mode })} + /> + )} + {hasImage && ( + updateResponsive({ image: mode })} + /> + )} + {bodyFields.map((field) => ( + updateFieldMode(field.id, mode)} + /> + ))} +
+
+ ); +} + +function ResponsiveDisplayRow({ + label, + sublabel, + value, + onChange, +}: { + label: string; + sublabel?: string; + value: ResponsiveDisplayMode; + onChange: (mode: ResponsiveDisplayMode) => void; +}) { + return ( +
+
+ {label} + {sublabel && ( + + {sublabel} + + )} +
+
+ {RESPONSIVE_MODES.map((mode) => ( + + ))} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx index 6e417c1e..aea93555 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -60,6 +60,15 @@ PopComponentRegistry.registerComponent({ configPanel: PopCardListConfigPanel, preview: PopCardListPreviewComponent, defaultProps: defaultConfig, + connectionMeta: { + sendable: [ + { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" }, + { key: "cart_item_added", label: "담기 완료", type: "event", description: "카드 담기 시 해당 행 + 수량 데이터 전달" }, + ], + receivable: [ + { key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, + ], + }, touchOptimized: true, supportedDevices: ["mobile", "tablet"], }); diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 632ae01a..a9a0a83a 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -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,25 +455,40 @@ export const CARD_SCROLL_DIRECTION_LABELS: Record = 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; // 증감 단위 + unit?: string; // 단위 (예: "EA") + limitColumn?: string; // 제한 기준 컬럼 (해당 행의 이 컬럼 값이 최대값) + saveTable?: string; // 저장 대상 테이블 + saveColumn?: string; // 저장 대상 컬럼 + /** @deprecated limitColumn 사용 */ + maxColumn?: string; + /** @deprecated 미사용, 하위 호환용 */ + label?: string; + /** @deprecated packageConfig로 이동, 하위 호환용 */ + showPackageUnit?: boolean; } -// ----- 카드 내 계산 필드 설정 ----- +// ----- 포장등록 설정 ----- -export interface CardCalculatedFieldConfig { - enabled: boolean; - label?: string; // 표시 라벨 (예: "미입고") - formula: string; // 계산식 (예: "order_qty - inbound_qty") - sourceColumns: string[]; // 계산에 사용되는 컬럼들 - resultColumn?: string; // 결과를 저장할 컬럼 (선택) - unit?: string; // 단위 (예: "EA") +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 } // ----- 담기 버튼 데이터 구조 (추후 API 연동 시 이 shape 그대로 사용) ----- @@ -464,6 +497,7 @@ export interface CartItem { row: Record; // 카드 원본 행 데이터 quantity: number; // 입력 수량 packageUnit?: string; // 포장 단위 (box/bag/pack/bundle/roll/barrel) + packageEntries?: PackageEntry[]; // 포장 내역 (2단계 계산 시) } // ----- 담기 버튼 액션 설정 (pop-icon 스타일 + 장바구니 연동) ----- @@ -533,31 +567,39 @@ export const CARD_PRESET_SPECS: Record = { }, }; +// ----- 반응형 표시 모드 ----- + +export type ResponsiveDisplayMode = "required" | "shrink" | "hidden"; + +export const RESPONSIVE_DISPLAY_LABELS: Record = { + required: "필수", + shrink: "축소", + hidden: "숨김", +}; + +export interface CardResponsiveConfig { + code?: ResponsiveDisplayMode; + title?: ResponsiveDisplayMode; + image?: ResponsiveDisplayMode; + fields?: Record; +} + +// ----- 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; } From 0ca031282be1a7d8d35b8832a8857f9c337b3ee1 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 26 Feb 2026 16:00:07 +0900 Subject: [PATCH 02/25] =?UTF-8?q?feat(pop-cart):=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B5=AC=ED=98=84=20+=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=A0=81=20=EC=BB=AC=EB=9F=BC=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 장바구니 담기 -> DB 저장 전체 플로우 구현 및 검증 완료. - useCartSync 훅 신규: DB(cart_items) <-> 로컬 상태 동기화, dirty check, 일괄 저장 - pop-button cart 프리셋: 배지 표시, 저장 트리거, 확인 모달, 3색 데이터 흐름 시각화 - pop-card-list: 담기/취소 UI, cart_save_trigger 수신 시 saveToDb 실행 - 선택적 컬럼 저장: RowDataMode(all/selected) + 연결 기반 자동 컬럼 로딩 - ComponentEditorPanel: allComponents/connections/componentId를 ConfigPanel에 전달 - connectionMeta: cart_save_trigger/cart_updated/cart_save_completed 이벤트 정의 - ConnectionEditor: 이벤트 타입 연결 구분 (데이터 vs 이벤트) - types.ts: CartItemWithId, CartSyncStatus, CartButtonConfig 등 타입 추가 - 접근성: NumberInputModal/PackageUnitModal에 DialogTitle 추가 Made-with: Cursor --- docker/dev/docker-compose.frontend.mac.yml | 1 + .../designer/panels/ComponentEditorPanel.tsx | 9 +- .../pop/designer/panels/ConnectionEditor.tsx | 38 +- frontend/hooks/pop/index.ts | 4 + frontend/hooks/pop/useCartSync.ts | 338 ++++++++++ .../registry/pop-components/pop-button.tsx | 626 ++++++++++++++++-- .../pop-card-list/NumberInputModal.tsx | 9 +- .../pop-card-list/PackageUnitModal.tsx | 5 +- .../pop-card-list/PopCardListComponent.tsx | 147 ++-- .../pop-card-list/PopCardListConfig.tsx | 135 ++-- .../pop-components/pop-card-list/index.tsx | 8 +- .../pop-search/PopSearchConfig.tsx | 89 ++- frontend/lib/registry/pop-components/types.ts | 38 +- frontend/next.config.mjs | 4 +- 14 files changed, 1238 insertions(+), 213 deletions(-) create mode 100644 frontend/hooks/pop/useCartSync.ts diff --git a/docker/dev/docker-compose.frontend.mac.yml b/docker/dev/docker-compose.frontend.mac.yml index 6428d481..eda932da 100644 --- a/docker/dev/docker-compose.frontend.mac.yml +++ b/docker/dev/docker-compose.frontend.mac.yml @@ -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: diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index f605b513..12c21e4f 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -213,6 +213,8 @@ export default function ComponentEditorPanel({ previewPageIndex={previewPageIndex} onPreviewPage={onPreviewPage} modals={modals} + allComponents={allComponents} + connections={connections} /> @@ -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} /> ) : (
diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx index 2e92d602..725b4f3f 100644 --- a/frontend/components/pop/designer/panels/ConnectionEditor.tsx +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -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({
)} - {/* 필터 설정 */} - {selectedTargetInput && ( + {/* 필터 설정: event 타입 연결이면 숨김 */} + {selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (

필터할 컬럼

@@ -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, diff --git a/frontend/hooks/pop/index.ts b/frontend/hooks/pop/index.ts index 3a6c792b..7ae7e953 100644 --- a/frontend/hooks/pop/index.ts +++ b/frontend/hooks/pop/index.ts @@ -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"; diff --git a/frontend/hooks/pop/useCartSync.ts b/frontend/hooks/pop/useCartSync.ts new file mode 100644 index 00000000..e3b76ed5 --- /dev/null +++ b/frontend/hooks/pop/useCartSync.ts @@ -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; + loadFromDb: () => Promise; + resetToSaved: () => void; +} + +// ===== DB 행 -> CartItemWithId 변환 ===== + +function dbRowToCartItem(dbRow: Record): CartItemWithId { + let rowData: Record = {}; + 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; + } + } 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 { + // 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([]); + const [savedItems, setSavedItems] = useState([]); + const [syncStatus, setSyncStatus] = useState("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 => { + 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[] = []; + + 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, + }; +} diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index b5532c30..a9ea0ece 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -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 = { 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> = { 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 ( +
+ + {source} + + +
+ + {target} + + {desc && ( +

{desc}

+ )} +
+
+ ); +} + /** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */ const LUCIDE_ICON_MAP: Record = { 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 | 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 ( <>
- + + {/* 장바구니 배지 */} + {isCartMode && cartCount > 0 && ( +
+ {cartCount} +
)} - > - {iconName && ( - - )} - {!isIconOnly && {buttonLabel}} - +
- {/* 확인 다이얼로그 (usePopAction의 pendingConfirm 상태로 제어) */} + {/* 장바구니 확인 다이얼로그 */} + + + + + 장바구니 저장 + + + {config?.confirm?.message || `${cartCount}개 항목을 장바구니에 저장하시겠습니까?`} + + + + + 취소 + + { + setShowCartConfirm(false); + handleCartSave(); + }} + className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" + > + 저장 + + + + + + {/* 일반 확인 다이얼로그 */} { if (!open) cancelConfirm(); }}> @@ -420,14 +649,117 @@ export function PopButtonComponent({ interface PopButtonConfigPanelProps { config: PopButtonConfig; onUpdate: (config: PopButtonConfig) => void; + allComponents?: { id: string; type: string; config?: Record }[]; + 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(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 | undefined; + const dataSource = cfg?.dataSource as Record | 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,44 +886,203 @@ export function PopButtonConfigPanel({
- {/* 메인 액션 */} - -
- {/* 액션 타입 */} -
- - - {!isCustom && ( -

- 프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택 -

- )} -
+ {/* 장바구니 설정 (cart 프리셋 전용) */} + {config?.preset === "cart" && ( + <> + +
+
+ + + onUpdate({ + ...config, + cart: { ...config.cart, cartScreenId: e.target.value }, + }) + } + placeholder="저장 후 이동할 POP 화면 ID" + className="h-8 text-xs" + /> +

+ 저장 완료 후 이동할 장바구니 리스트 화면 ID입니다. + 비어있으면 이동 없이 저장만 합니다. +

+
+
- {/* 액션별 추가 설정 */} - -
+ {/* 데이터 저장 흐름 시각화 */} + +
+

+ 카드 목록에서 "담기" 클릭 시 아래와 같이 cart_items 테이블에 저장됩니다. +

+ +
+ {/* 사용자 입력 데이터 */} +
+

사용자 입력

+ + + + +
+ + {/* 원본 데이터 */} +
+

원본 행 데이터

+ + {/* 저장 모드 선택 */} +
+ 저장 모드: + +
+ + {config?.cart?.rowDataMode === "selected" ? ( + <> + {/* 선택 저장 모드: 컬럼 목록 관리 */} +
+ {connectedTableName ? ( +

+ 연결: {connectedTableName} +

+ ) : ( +

+ 카드 목록과 연결(cart_save_trigger)하면 컬럼이 자동으로 표시됩니다. +

+ )} + + {colLoading && ( +

컬럼 불러오는 중...

+ )} + + {/* 불러온 컬럼 체크박스 */} + {loadedColumns.length > 0 && ( +
+ {loadedColumns.map((col) => { + const isChecked = (config?.cart?.selectedColumns || []).includes(col.name); + return ( + + ); + })} +
+ )} + + {/* 선택된 컬럼 요약 */} + {(config?.cart?.selectedColumns?.length ?? 0) > 0 ? ( + + ) : ( +

+ 저장할 컬럼을 선택하세요. 미선택 시 전체 저장됩니다. +

+ )} +
+ + ) : ( + + )} + + + +
+ + {/* 시스템 자동 */} +
+

자동 설정

+ + + + + +
+
+ +

+ 장바구니 목록 화면에서 row_data의 JSON을 풀어서 + 최종 대상 테이블로 매핑할 수 있습니다. +

+
+ + )} + + {/* 메인 액션 (cart 프리셋에서는 숨김) */} + {config?.preset !== "cart" && ( + <> + +
+
+ + + {!isCustom && ( +

+ 프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택 +

+ )} +
+ +
+ + )} {/* 확인 다이얼로그 */} @@ -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"], }); diff --git a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx index 209234ec..6e818a0c 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx @@ -6,8 +6,10 @@ 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, @@ -62,7 +64,7 @@ export function NumberInputModal({ () => entries.reduce((sum, e) => sum + e.totalQuantity, 0), [entries] ); - const remainingQuantity = maxValue - entriesTotal; + const remainingQuantity = Math.max(0, maxValue - entriesTotal); useEffect(() => { if (open) { @@ -81,7 +83,7 @@ export function NumberInputModal({ : step === "package_count" ? 9999 : step === "quantity_per_unit" - ? remainingQuantity > 0 ? remainingQuantity : maxValue + ? Math.max(1, remainingQuantity) : maxValue; const handleNumberClick = (num: string) => { @@ -117,7 +119,7 @@ export function NumberInputModal({ if (step === "quantity_per_unit") { if (numericValue <= 0 || !selectedUnit) return; - const total = packageCount * numericValue; + const total = Math.min(packageCount * numericValue, remainingQuantity); const newEntry: PackageEntry = { unitId: selectedUnit.id, unitLabel: selectedUnit.label, @@ -228,6 +230,7 @@ export function NumberInputModal({ + 수량 입력 {/* 헤더 */}
diff --git a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx index ad050744..bc32805c 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx @@ -2,12 +2,14 @@ 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"; @@ -60,8 +62,9 @@ export function PackageUnitModal({ + 포장 단위 선택
-

📦 포장 단위 선택

+

포장 단위 선택

diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index 9d21a8d4..e3e7dc4c 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -35,6 +35,7 @@ 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"; const LUCIDE_ICON_MAP: Record = { @@ -163,9 +164,14 @@ export function PopCardListComponent({ const dataSource = config?.dataSource; const template = config?.cardTemplate; - const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default"); + const { subscribe, publish } = usePopEvent(screenId || "default"); const router = useRouter(); + // 장바구니 DB 동기화 + const sourceTableName = dataSource?.tableName || ""; + const cartType = config?.cartAction?.cartType; + const cart = useCartSync(screenId || "", sourceTableName, cartType); + // 데이터 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); @@ -209,6 +215,35 @@ export function PopCardListComponent({ 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; @@ -229,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); }); @@ -239,7 +276,7 @@ export function PopCardListComponent({ // 이미지 URL 없는 항목 카운트 (toast 중복 방지용) const missingImageCountRef = useRef(0); - const toastShownRef = useRef(false); + const cardSizeKey = config?.cardSize || "large"; const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large; @@ -256,7 +293,7 @@ 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)); // 행 수: 설정값 그대로 사용 (containerHeight 연동 시 피드백 루프 위험) const gridRows = configGridRows; @@ -428,7 +465,6 @@ export function PopCardListComponent({ setLoading(true); setError(null); missingImageCountRef.current = 0; - toastShownRef.current = false; try { const filters: Record = {}; @@ -476,25 +512,11 @@ export function PopCardListComponent({ fetchData(); }, [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]); @@ -558,9 +580,10 @@ export function PopCardListComponent({ }} > {displayCards.map((row, index) => { - const rowKey = template?.header?.codeField && row[template.header.codeField] + const codeValue = template?.header?.codeField && row[template.header.codeField] ? String(row[template.header.codeField]) - : `card-${index}`; + : null; + const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`; return ( ); })} @@ -652,10 +676,11 @@ function Card({ packageConfig, cartAction, publish, - getSharedData, - setSharedData, router, onSelect, + cart, + codeFieldName, + parentComponentId, }: { row: RowData; template?: CardTemplateConfig; @@ -664,10 +689,11 @@ function Card({ packageConfig?: CardPackageConfig; cartAction?: CardCartActionConfig; publish: (eventName: string, payload?: unknown) => void; - getSharedData: (key: string) => T | undefined; - setSharedData: (key: string, value: unknown) => void; router: ReturnType; onSelect?: (row: RowData) => void; + cart: ReturnType; + codeFieldName?: string; + parentComponentId?: string; }) { const header = template?.header; const image = template?.image; @@ -677,11 +703,24 @@ function Card({ const [packageUnit, setPackageUnit] = useState(undefined); const [packageEntries, setPackageEntries] = useState([]); 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; + // 장바구니 상태: 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]) @@ -734,8 +773,10 @@ function Card({ setPackageEntries(entries || []); }; - // 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글 + // 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달 const handleCartAdd = () => { + if (!rowKey) return; + const cartItem: CartItem = { row, quantity: inputValue, @@ -743,30 +784,26 @@ function Card({ packageEntries: packageEntries.length > 0 ? packageEntries : undefined, }; - const existing = getSharedData("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("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 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영) @@ -780,7 +817,11 @@ function Card({ return (
{/* 헤더 영역 */} {(codeValue !== null || titleValue !== null) && ( -
+
{codeValue !== null && ( {/* 담기 버튼 설정 */} - + onUpdate({ cartAction })} + cardTemplate={template} + tableName={dataSource.tableName} /> @@ -2136,14 +2138,16 @@ function LimitSettingsSection({ function CartActionSettingsSection({ cartAction, onUpdate, + cardTemplate, + tableName, }: { cartAction?: CardCartActionConfig; onUpdate: (cartAction: CardCartActionConfig) => void; + cardTemplate?: CardTemplateConfig; + tableName?: string; }) { const action: CardCartActionConfig = cartAction || { - navigateMode: "none", - iconType: "lucide", - iconValue: "ShoppingCart", + saveMode: "cart", label: "담기", cancelLabel: "취소", }; @@ -2152,82 +2156,63 @@ function CartActionSettingsSection({ onUpdate({ ...action, ...partial }); }; + const saveMode = action.saveMode || "cart"; + + // 카드 템플릿에서 사용 중인 필드 목록 수집 + const usedFields = useMemo(() => { + const fields: { name: string; label: string; source: string }[] = []; + + if (cardTemplate?.header?.codeField) { + fields.push({ name: cardTemplate.header.codeField, label: "코드 (헤더)", source: "헤더" }); + } + if (cardTemplate?.header?.titleField) { + fields.push({ name: cardTemplate.header.titleField, label: "제목 (헤더)", source: "헤더" }); + } + if (cardTemplate?.body?.fields) { + for (const f of cardTemplate.body.fields) { + if (f.valueType === "column" && f.columnName) { + fields.push({ name: f.columnName, label: f.label || f.columnName, source: "본문" }); + } + } + } + return fields; + }, [cardTemplate]); + return (
- {/* 네비게이션 모드 */} + {/* 저장 방식 */}
- +
- {/* 대상 화면 ID (screen 모드일 때만) */} - {action.navigateMode === "screen" && ( + {/* 장바구니 구분값 */} + {saveMode === "cart" && (
- + update({ targetScreenId: e.target.value })} - placeholder="예: 15" + value={action.cartType || ""} + onChange={(e) => update({ cartType: e.target.value })} + placeholder="예: purchase_inbound" className="mt-1 h-7 text-xs" />

- 담기 클릭 시 이동할 POP 화면의 screenId + 장바구니 화면에서 이 값으로 필터링하여 해당 품목만 표시합니다.

)} - {/* 아이콘 타입 */} -
- - -
- - {/* 아이콘 값 */} -
- - update({ iconValue: e.target.value })} - placeholder={ - action.iconType === "emoji" ? "예: 🛒" : "예: ShoppingCart" - } - className="mt-1 h-7 text-xs" - /> - {action.iconType === "lucide" && ( -

- PascalCase로 입력 (ShoppingCart, Package, Truck 등) -

- )} -
- {/* 담기 라벨 */}
@@ -2249,6 +2234,40 @@ function CartActionSettingsSection({ className="mt-1 h-7 text-xs" />
+ + {/* 저장 데이터 정보 (읽기 전용) */} +
+ +
+

+ 담기 시 {tableName || "(테이블 미선택)"}의 + 모든 컬럼 데이터가 JSON으로 저장됩니다. +

+ + {usedFields.length > 0 && ( +
+

카드에 표시 중인 필드:

+
+ {usedFields.map((f) => ( +
+ + {f.source} + + {f.name} + - {f.label} +
+ ))} +
+
+ )} + +

+ + 입력 수량, 포장 단위 등 추가 정보도 함께 저장됩니다. +
+ 장바구니 목록 화면에서 대상 테이블로 매핑 설정이 가능합니다. +

+
+
); } diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx index aea93555..738dfa4c 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -41,9 +41,7 @@ const defaultConfig: PopCardListConfig = { gridRows: 2, // 담기 버튼 기본 설정 cartAction: { - navigateMode: "none", - iconType: "lucide", - iconValue: "ShoppingCart", + saveMode: "cart", label: "담기", cancelLabel: "취소", }, @@ -63,10 +61,12 @@ PopComponentRegistry.registerComponent({ connectionMeta: { sendable: [ { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" }, - { key: "cart_item_added", label: "담기 완료", type: "event", 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, diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index 3993dc48..96507984 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -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([]); 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) { 테이블 목록 로딩...
) : ( - + + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((t) => ( + { + updateModal({ + tableName: t.tableName, + displayColumns: [], + searchColumns: [], + displayField: "", + valueField: "", + columnLabels: undefined, + }); + setOpenTableCombo(false); + }} + className="text-xs" + > + +
+ {t.displayName || t.tableName} + {t.displayName && t.displayName !== t.tableName && ( + {t.tableName} + )} +
+
+ ))} +
+
+
+
+
)}
diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index a9a0a83a..d3c77233 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -491,7 +491,7 @@ export interface PackageEntry { totalQuantity: number; // 합계 = packageCount * quantityPerUnit } -// ----- 담기 버튼 데이터 구조 (추후 API 연동 시 이 shape 그대로 사용) ----- +// ----- 담기 버튼 데이터 구조 (로컬 상태용) ----- export interface CartItem { row: Record; // 카드 원본 행 데이터 @@ -500,15 +500,39 @@ export interface CartItem { 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 아이콘명 또는 이모지 값 - label?: string; // 담기 라벨 (기본: "담기") - cancelLabel?: string; // 취소 라벨 (기본: "취소") + 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 전체 설정 ----- diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index f6f1907e..22b80896 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -24,7 +24,7 @@ const nextConfig = { // 로컬 개발: http://127.0.0.1:8080 사용 async rewrites() { // Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 - const backendUrl = process.env.SERVER_API_URL || "http://pms-backend-mac:8080"; + const backendUrl = process.env.SERVER_API_URL || "http://localhost:8080"; return [ { source: "/api/:path*", @@ -50,7 +50,7 @@ const nextConfig = { // 환경 변수 (런타임에 읽기) env: { // Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://pms-backend-mac:8080/api", + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api", }, }; From 20167ad35943b59dc028991e3264209b05730220 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 26 Feb 2026 16:07:15 +0900 Subject: [PATCH 03/25] feat: Implement advanced filtering capabilities in entity search - Added a new helper function `applyFilters` to handle dynamic filter conditions for entity search queries. - Enhanced the `getDistinctColumnValues` and `getEntityOptions` endpoints to support JSON array filters, allowing for more flexible data retrieval based on specified conditions. - Updated the frontend components to integrate filter conditions, improving user interaction and data management in selection components. - Introduced new filter options in the V2Select component, enabling users to define and apply various filter criteria dynamically. --- .../src/controllers/entitySearchController.ts | 128 +++++++- frontend/components/v2/V2Select.tsx | 82 ++++- .../v2/config-panels/V2SelectConfigPanel.tsx | 293 +++++++++++++++++- .../modal-repeater-table/RepeaterTable.tsx | 77 ++++- .../v2-table-list/TableListComponent.tsx | 6 - .../v2-table-list/TableListConfigPanel.tsx | 33 +- frontend/next.config.mjs | 6 +- frontend/package-lock.json | 42 +-- frontend/types/v2-components.ts | 20 +- scripts/dev/start-npm.sh | 66 ++++ 10 files changed, 689 insertions(+), 64 deletions(-) create mode 100755 scripts/dev/start-npm.sh diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 3ece2ce7..62fc8bbe 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -3,16 +3,115 @@ import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +/** + * 필터 조건을 WHERE절에 적용하는 공통 헬퍼 + * filters JSON 배열: [{ column, operator, value }] + */ +function applyFilters( + filtersJson: string | undefined, + existingColumns: Set, + whereConditions: string[], + params: any[], + startParamIndex: number, + tableName: string, +): number { + let paramIndex = startParamIndex; + + if (!filtersJson) return paramIndex; + + let filters: Array<{ column: string; operator: string; value: unknown }>; + try { + filters = JSON.parse(filtersJson as string); + } catch { + logger.warn("filters JSON 파싱 실패", { tableName, filtersJson }); + return paramIndex; + } + + if (!Array.isArray(filters)) return paramIndex; + + for (const filter of filters) { + const { column, operator = "=", value } = filter; + if (!column || !existingColumns.has(column)) { + logger.warn("필터 컬럼 미존재 제외", { tableName, column }); + continue; + } + + switch (operator) { + case "=": + whereConditions.push(`"${column}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "!=": + whereConditions.push(`"${column}" != $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case ">": + case "<": + case ">=": + case "<=": + whereConditions.push(`"${column}" ${operator} $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "in": { + const inVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (inVals.length > 0) { + const ph = inVals.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${column}" IN (${ph})`); + params.push(...inVals); + paramIndex += inVals.length; + } + break; + } + case "notIn": { + const notInVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (notInVals.length > 0) { + const ph = notInVals.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${column}" NOT IN (${ph})`); + params.push(...notInVals); + paramIndex += notInVals.length; + } + break; + } + case "like": + whereConditions.push(`"${column}"::text ILIKE $${paramIndex}`); + params.push(`%${value}%`); + paramIndex++; + break; + case "isNull": + whereConditions.push(`"${column}" IS NULL`); + break; + case "isNotNull": + whereConditions.push(`"${column}" IS NOT NULL`); + break; + default: + whereConditions.push(`"${column}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + } + } + + return paramIndex; +} + /** * 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용) * GET /api/entity/:tableName/distinct/:columnName * * 해당 테이블의 해당 컬럼에서 DISTINCT 값을 조회하여 선택박스 옵션으로 반환 + * + * Query Params: + * - labelColumn: 별도의 라벨 컬럼 (선택) + * - filters: JSON 배열 형태의 필터 조건 (선택) + * 예: [{"column":"status","operator":"=","value":"active"}] */ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) { try { const { tableName, columnName } = req.params; - const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼 + const { labelColumn, filters: filtersParam } = req.query; // 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -68,6 +167,16 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re whereConditions.push(`"${columnName}" IS NOT NULL`); whereConditions.push(`"${columnName}" != ''`); + // 필터 조건 적용 + paramIndex = applyFilters( + filtersParam as string | undefined, + existingColumns, + whereConditions, + params, + paramIndex, + tableName, + ); + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; @@ -88,6 +197,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re columnName, labelColumn: effectiveLabelColumn, companyCode, + hasFilters: !!filtersParam, rowCount: result.rowCount, }); @@ -111,11 +221,14 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re * Query Params: * - value: 값 컬럼 (기본: id) * - label: 표시 컬럼 (기본: name) + * - fields: 추가 반환 컬럼 (콤마 구분) + * - filters: JSON 배열 형태의 필터 조건 (선택) + * 예: [{"column":"status","operator":"=","value":"active"}] */ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) { try { const { tableName } = req.params; - const { value = "id", label = "name", fields } = req.query; + const { value = "id", label = "name", fields, filters: filtersParam } = req.query; // tableName 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -163,6 +276,16 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) paramIndex++; } + // 필터 조건 적용 + paramIndex = applyFilters( + filtersParam as string | undefined, + existingColumns, + whereConditions, + params, + paramIndex, + tableName, + ); + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; @@ -195,6 +318,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) valueColumn, labelColumn: effectiveLabelColumn, companyCode, + hasFilters: !!filtersParam, rowCount: result.rowCount, extraFields: extraColumns ? true : false, }); diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index fe21b790..f0021eeb 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -23,7 +23,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -import { V2SelectProps, SelectOption } from "@/types/v2-components"; +import { V2SelectProps, SelectOption, V2SelectFilter } from "@/types/v2-components"; import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react"; import { apiClient } from "@/lib/api/client"; import V2FormContext from "./V2FormContext"; @@ -655,6 +655,7 @@ export const V2Select = forwardRef( const labelColumn = config.labelColumn; const apiEndpoint = config.apiEndpoint; const staticOptions = config.options; + const configFilters = config.filters; // 계층 코드 연쇄 선택 관련 const hierarchical = config.hierarchical; @@ -662,6 +663,54 @@ export const V2Select = forwardRef( // FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null) const formContext = useContext(V2FormContext); + + /** + * 필터 조건을 API 전달용 JSON으로 변환 + * field/user 타입은 런타임 값으로 치환 + */ + const resolvedFiltersJson = useMemo(() => { + if (!configFilters || configFilters.length === 0) return undefined; + + const resolved: Array<{ column: string; operator: string; value: unknown }> = []; + + for (const f of configFilters) { + const vt = f.valueType || "static"; + + // isNull/isNotNull은 값 불필요 + if (f.operator === "isNull" || f.operator === "isNotNull") { + resolved.push({ column: f.column, operator: f.operator, value: null }); + continue; + } + + let resolvedValue: unknown = f.value; + + if (vt === "field" && f.fieldRef) { + // 다른 폼 필드 참조 + if (formContext) { + resolvedValue = formContext.getValue(f.fieldRef); + } else { + const fd = (props as any).formData; + resolvedValue = fd?.[f.fieldRef]; + } + // 참조 필드 값이 비어있으면 이 필터 건너뜀 + if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") continue; + } else if (vt === "user" && f.userField) { + // 로그인 사용자 정보 참조 (props에서 가져옴) + const userMap: Record = { + companyCode: (props as any).companyCode, + userId: (props as any).userId, + deptCode: (props as any).deptCode, + userName: (props as any).userName, + }; + resolvedValue = userMap[f.userField]; + if (!resolvedValue) continue; + } + + resolved.push({ column: f.column, operator: f.operator, value: resolvedValue }); + } + + return resolved.length > 0 ? JSON.stringify(resolved) : undefined; + }, [configFilters, formContext, props]); // 부모 필드의 값 계산 const parentValue = useMemo(() => { @@ -684,6 +733,13 @@ export const V2Select = forwardRef( } }, [parentValue, hierarchical, source]); + // 필터 조건이 변경되면 옵션 다시 로드 + useEffect(() => { + if (resolvedFiltersJson !== undefined) { + setOptionsLoaded(false); + } + }, [resolvedFiltersJson]); + useEffect(() => { // 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외) if (optionsLoaded && source !== "static") { @@ -731,11 +787,13 @@ export const V2Select = forwardRef( } } else if (source === "db" && table) { // DB 테이블에서 로드 + const dbParams: Record = { + value: valueColumn || "id", + label: labelColumn || "name", + }; + if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson; const response = await apiClient.get(`/entity/${table}/options`, { - params: { - value: valueColumn || "id", - label: labelColumn || "name", - }, + params: dbParams, }); const data = response.data; if (data.success && data.data) { @@ -745,8 +803,10 @@ export const V2Select = forwardRef( // 엔티티(참조 테이블)에서 로드 const valueCol = entityValueColumn || "id"; const labelCol = entityLabelColumn || "name"; + const entityParams: Record = { value: valueCol, label: labelCol }; + if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson; const response = await apiClient.get(`/entity/${entityTable}/options`, { - params: { value: valueCol, label: labelCol }, + params: entityParams, }); const data = response.data; if (data.success && data.data) { @@ -790,11 +850,13 @@ export const V2Select = forwardRef( } } else if (source === "select" || source === "distinct") { // 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회 - // tableName, columnName은 props에서 가져옴 - // 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀 const isValidColumnName = columnName && !columnName.startsWith("comp_"); if (tableName && isValidColumnName) { - const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`); + const distinctParams: Record = {}; + if (resolvedFiltersJson) distinctParams.filters = resolvedFiltersJson; + const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`, { + params: distinctParams, + }); const data = response.data; if (data.success && data.data) { fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ @@ -818,7 +880,7 @@ export const V2Select = forwardRef( }; loadOptions(); - }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); + }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]); // 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지 const autoFillTargets = useMemo(() => { diff --git a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx index d631f454..66ebb369 100644 --- a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx @@ -5,15 +5,16 @@ * 통합 선택 컴포넌트의 세부 설정을 관리합니다. */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; -import { Plus, Trash2, Loader2 } from "lucide-react"; +import { Plus, Trash2, Loader2, Filter } from "lucide-react"; import { apiClient } from "@/lib/api/client"; +import type { V2SelectFilter } from "@/types/v2-components"; interface ColumnOption { columnName: string; @@ -25,6 +26,238 @@ interface CategoryValueOption { valueLabel: string; } +const OPERATOR_OPTIONS = [ + { value: "=", label: "같음 (=)" }, + { value: "!=", label: "다름 (!=)" }, + { value: ">", label: "초과 (>)" }, + { value: "<", label: "미만 (<)" }, + { value: ">=", label: "이상 (>=)" }, + { value: "<=", label: "이하 (<=)" }, + { value: "in", label: "포함 (IN)" }, + { value: "notIn", label: "미포함 (NOT IN)" }, + { value: "like", label: "유사 (LIKE)" }, + { value: "isNull", label: "NULL" }, + { value: "isNotNull", label: "NOT NULL" }, +] as const; + +const VALUE_TYPE_OPTIONS = [ + { value: "static", label: "고정값" }, + { value: "field", label: "폼 필드 참조" }, + { value: "user", label: "로그인 사용자" }, +] as const; + +const USER_FIELD_OPTIONS = [ + { value: "companyCode", label: "회사코드" }, + { value: "userId", label: "사용자ID" }, + { value: "deptCode", label: "부서코드" }, + { value: "userName", label: "사용자명" }, +] as const; + +/** + * 필터 조건 설정 서브 컴포넌트 + */ +const FilterConditionsSection: React.FC<{ + filters: V2SelectFilter[]; + columns: ColumnOption[]; + loadingColumns: boolean; + targetTable: string; + onFiltersChange: (filters: V2SelectFilter[]) => void; +}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => { + + const addFilter = () => { + onFiltersChange([ + ...filters, + { column: "", operator: "=", valueType: "static", value: "" }, + ]); + }; + + const updateFilter = (index: number, patch: Partial) => { + const updated = [...filters]; + updated[index] = { ...updated[index], ...patch }; + + // valueType 변경 시 관련 필드 초기화 + if (patch.valueType) { + if (patch.valueType === "static") { + updated[index].fieldRef = undefined; + updated[index].userField = undefined; + } else if (patch.valueType === "field") { + updated[index].value = undefined; + updated[index].userField = undefined; + } else if (patch.valueType === "user") { + updated[index].value = undefined; + updated[index].fieldRef = undefined; + } + } + + // isNull/isNotNull 연산자는 값 불필요 + if (patch.operator === "isNull" || patch.operator === "isNotNull") { + updated[index].value = undefined; + updated[index].fieldRef = undefined; + updated[index].userField = undefined; + updated[index].valueType = "static"; + } + + onFiltersChange(updated); + }; + + const removeFilter = (index: number) => { + onFiltersChange(filters.filter((_, i) => i !== index)); + }; + + const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull"; + + return ( +
+
+
+ + +
+ +
+ +

+ {targetTable} 테이블에서 옵션을 불러올 때 적용할 조건 +

+ + {loadingColumns && ( +
+ + 컬럼 목록 로딩 중... +
+ )} + + {filters.length === 0 && ( +

+ 필터 조건이 없습니다 +

+ )} + +
+ {filters.map((filter, index) => ( +
+ {/* 행 1: 컬럼 + 연산자 + 삭제 */} +
+ {/* 컬럼 선택 */} + + + {/* 연산자 선택 */} + + + {/* 삭제 버튼 */} + +
+ + {/* 행 2: 값 유형 + 값 입력 (isNull/isNotNull 제외) */} + {needsValue(filter.operator) && ( +
+ {/* 값 유형 */} + + + {/* 값 입력 영역 */} + {(filter.valueType || "static") === "static" && ( + updateFilter(index, { value: e.target.value })} + placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"} + className="h-7 flex-1 text-[11px]" + /> + )} + + {filter.valueType === "field" && ( + updateFilter(index, { fieldRef: e.target.value })} + placeholder="참조할 필드명 (columnName)" + className="h-7 flex-1 text-[11px]" + /> + )} + + {filter.valueType === "user" && ( + + )} +
+ )} +
+ ))} +
+
+ ); +}; + interface V2SelectConfigPanelProps { config: Record; onChange: (config: Record) => void; @@ -53,10 +286,52 @@ export const V2SelectConfigPanel: React.FC = ({ const [categoryValues, setCategoryValues] = useState([]); const [loadingCategoryValues, setLoadingCategoryValues] = useState(false); + // 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼) + const [filterColumns, setFilterColumns] = useState([]); + const [loadingFilterColumns, setLoadingFilterColumns] = useState(false); + const updateConfig = (field: string, value: any) => { onChange({ ...config, [field]: value }); }; + // 필터 대상 테이블 결정 + const filterTargetTable = useMemo(() => { + const src = config.source || "static"; + if (src === "entity") return config.entityTable; + if (src === "db") return config.table; + if (src === "distinct" || src === "select") return tableName; + return null; + }, [config.source, config.entityTable, config.table, tableName]); + + // 필터 대상 테이블의 컬럼 로드 + useEffect(() => { + if (!filterTargetTable) { + setFilterColumns([]); + return; + } + + const loadFilterColumns = async () => { + setLoadingFilterColumns(true); + try { + const response = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`); + const data = response.data.data || response.data; + const columns = data.columns || data || []; + setFilterColumns( + columns.map((col: any) => ({ + columnName: col.columnName || col.column_name || col.name, + columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name, + })) + ); + } catch { + setFilterColumns([]); + } finally { + setLoadingFilterColumns(false); + } + }; + + loadFilterColumns(); + }, [filterTargetTable]); + // 카테고리 타입이면 source를 자동으로 category로 설정 useEffect(() => { if (isCategoryType && config.source !== "category") { @@ -518,6 +793,20 @@ export const V2SelectConfigPanel: React.FC = ({ />
)} + + {/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */} + {effectiveSource !== "static" && filterTargetTable && ( + <> + + updateConfig("filters", filters)} + /> + + )}
); }; diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 5ad6d0eb..d57ae60b 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -162,6 +162,79 @@ export function RepeaterTable({ // 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행) const initializedRef = useRef(false); + // 편집 가능한 컬럼 인덱스 목록 (방향키 네비게이션용) + const editableColIndices = useMemo( + () => visibleColumns.reduce((acc, col, idx) => { + if (col.editable && !col.calculated) acc.push(idx); + return acc; + }, []), + [visibleColumns], + ); + + // 방향키로 리피터 셀 간 이동 + const handleArrowNavigation = useCallback( + (e: React.KeyboardEvent) => { + const key = e.key; + if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return; + + const target = e.target as HTMLElement; + const cell = target.closest("[data-repeater-row]") as HTMLElement | null; + if (!cell) return; + + const row = Number(cell.dataset.repeaterRow); + const col = Number(cell.dataset.repeaterCol); + if (isNaN(row) || isNaN(col)) return; + + // 텍스트 입력 중 좌/우 방향키는 커서 이동에 사용하므로 무시 + if ((key === "ArrowLeft" || key === "ArrowRight") && target.tagName === "INPUT") { + const input = target as HTMLInputElement; + const len = input.value?.length ?? 0; + const pos = input.selectionStart ?? 0; + // 커서가 끝에 있을 때만 오른쪽 이동, 처음에 있을 때만 왼쪽 이동 + if (key === "ArrowRight" && pos < len) return; + if (key === "ArrowLeft" && pos > 0) return; + } + + let nextRow = row; + let nextColPos = editableColIndices.indexOf(col); + + switch (key) { + case "ArrowUp": + nextRow = Math.max(0, row - 1); + break; + case "ArrowDown": + nextRow = Math.min(data.length - 1, row + 1); + break; + case "ArrowLeft": + nextColPos = Math.max(0, nextColPos - 1); + break; + case "ArrowRight": + nextColPos = Math.min(editableColIndices.length - 1, nextColPos + 1); + break; + } + + const nextCol = editableColIndices[nextColPos]; + if (nextRow === row && nextCol === col) return; + + e.preventDefault(); + + const selector = `[data-repeater-row="${nextRow}"][data-repeater-col="${nextCol}"]`; + const nextCell = containerRef.current?.querySelector(selector) as HTMLElement | null; + if (!nextCell) return; + + const focusable = nextCell.querySelector( + 'input:not([disabled]), select:not([disabled]), [role="combobox"]:not([disabled]), button:not([disabled])', + ); + if (focusable) { + focusable.focus(); + if (focusable.tagName === "INPUT") { + (focusable as HTMLInputElement).select(); + } + } + }, + [editableColIndices, data.length], + ); + // DnD 센서 설정 const sensors = useSensors( useSensor(PointerSensor, { @@ -648,7 +721,7 @@ export function RepeaterTable({ return ( -
+
{renderCell(row, col, rowIndex)} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 1eaef469..30584fc4 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -5777,12 +5777,6 @@ export const TableListComponent: React.FC = ({ renderCheckboxHeader() ) : (
- {/* 🆕 편집 불가 컬럼 표시 */} - {column.editable === false && ( - - - - )} {columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "↑" : "↓"} diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index becd3c34..35f15596 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -1333,7 +1333,38 @@ export const TableListConfigPanel: React.FC = ({ /> {column.label || column.columnName} - + {isAdded && ( + + )} + {column.input_type || column.dataType}
diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index f6f1907e..b7b134ec 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -23,8 +23,7 @@ const nextConfig = { // Docker 환경: 컨테이너 이름(pms-backend-mac) 또는 SERVER_API_URL 사용 // 로컬 개발: http://127.0.0.1:8080 사용 async rewrites() { - // Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 - const backendUrl = process.env.SERVER_API_URL || "http://pms-backend-mac:8080"; + const backendUrl = process.env.SERVER_API_URL || "http://127.0.0.1:8080"; return [ { source: "/api/:path*", @@ -49,8 +48,7 @@ const nextConfig = { // 환경 변수 (런타임에 읽기) env: { - // Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://pms-backend-mac:8080/api", + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8080/api", }, }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d8b262a1..01edd32d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -262,7 +262,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -304,7 +303,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -338,7 +336,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2669,7 +2666,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.32.0", @@ -3323,7 +3319,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.6" }, @@ -3391,7 +3386,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3705,7 +3699,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -6206,7 +6199,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6217,7 +6209,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6260,7 +6251,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "license": "MIT", - "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -6343,7 +6333,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -6976,7 +6965,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8127,8 +8115,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3": { "version": "7.9.0", @@ -8450,7 +8437,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -9210,7 +9196,6 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9299,7 +9284,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9401,7 +9385,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10573,7 +10556,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11354,8 +11336,7 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/levn": { "version": "0.4.1", @@ -12684,7 +12665,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12978,7 +12958,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -13008,7 +12987,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -13057,7 +13035,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -13184,7 +13161,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13254,7 +13230,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -13305,7 +13280,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -13338,8 +13312,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-leaflet": { "version": "5.0.0", @@ -13647,7 +13620,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -13670,8 +13642,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/recharts/node_modules/redux-thunk": { "version": "3.1.0", @@ -14701,8 +14672,7 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -14790,7 +14760,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15139,7 +15108,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/types/v2-components.ts b/frontend/types/v2-components.ts index 88ac1691..6ce5a974 100644 --- a/frontend/types/v2-components.ts +++ b/frontend/types/v2-components.ts @@ -139,6 +139,23 @@ export interface SelectOption { label: string; } +/** + * V2Select 필터 조건 + * 옵션 데이터를 조회할 때 적용할 WHERE 조건 + */ +export interface V2SelectFilter { + column: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like" | "isNull" | "isNotNull"; + /** 값 유형: static=고정값, field=다른 폼 필드 참조, user=로그인 사용자 정보 */ + valueType?: "static" | "field" | "user"; + /** static일 때 고정값 */ + value?: unknown; + /** field일 때 참조할 폼 필드명 (columnName) */ + fieldRef?: string; + /** user일 때 참조할 사용자 필드 */ + userField?: "companyCode" | "userId" | "deptCode" | "userName"; +} + export interface V2SelectConfig { mode: V2SelectMode; source: V2SelectSource | "distinct" | "select"; // distinct/select 추가 (테이블 컬럼에서 자동 로드) @@ -151,7 +168,8 @@ export interface V2SelectConfig { table?: string; valueColumn?: string; labelColumn?: string; - filters?: Array<{ column: string; operator: string; value: unknown }>; + // 옵션 필터 조건 (모든 source에서 사용 가능) + filters?: V2SelectFilter[]; // 엔티티 연결 (source: entity) entityTable?: string; entityValueField?: string; diff --git a/scripts/dev/start-npm.sh b/scripts/dev/start-npm.sh new file mode 100755 index 00000000..7b7fc54a --- /dev/null +++ b/scripts/dev/start-npm.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +echo "============================================" +echo "WACE 솔루션 - npm 직접 실행 (Docker 없이)" +echo "============================================" +echo "" + +PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +LOG_DIR="$PROJECT_ROOT/scripts/dev/logs" +mkdir -p "$LOG_DIR" + +BACKEND_LOG="$LOG_DIR/backend.log" +FRONTEND_LOG="$LOG_DIR/frontend.log" + +# 기존 프로세스 정리 +echo "[1/4] 기존 프로세스 정리 중..." +lsof -ti:8080 | xargs kill -9 2>/dev/null +lsof -ti:9771 | xargs kill -9 2>/dev/null +echo " 완료" +echo "" + +# 백엔드 npm install + 실행 +echo "[2/4] 백엔드 의존성 설치 중..." +cd "$PROJECT_ROOT/backend-node" +npm install --silent +echo " 완료" +echo "" + +echo "[3/4] 백엔드 서버 시작 중 (포트 8080)..." +npm run dev > "$BACKEND_LOG" 2>&1 & +BACKEND_PID=$! +echo " PID: $BACKEND_PID" +echo "" + +# 프론트엔드 npm install + 실행 +echo "[4/4] 프론트엔드 의존성 설치 + 서버 시작 중 (포트 9771)..." +cd "$PROJECT_ROOT/frontend" +npm install --silent +npm run dev > "$FRONTEND_LOG" 2>&1 & +FRONTEND_PID=$! +echo " PID: $FRONTEND_PID" +echo "" + +sleep 3 + +echo "============================================" +echo "모든 서비스가 시작되었습니다!" +echo "============================================" +echo "" +echo " [BACKEND] http://localhost:8080/api" +echo " [FRONTEND] http://localhost:9771" +echo "" +echo " 백엔드 PID: $BACKEND_PID" +echo " 프론트엔드 PID: $FRONTEND_PID" +echo "" +echo " 프론트엔드 로그: tail -f $FRONTEND_LOG" +echo "" +echo "Ctrl+C 로 종료하면 백엔드/프론트엔드 모두 종료됩니다." +echo "============================================" +echo "" +echo "--- 백엔드 로그 출력 시작 ---" +echo "" + +trap "echo ''; echo '서비스를 종료합니다...'; kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit 0" SIGINT SIGTERM + +tail -f "$BACKEND_LOG" From 43ead0e7f2c912ece4ae807ad114bba35537ea8d Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 26 Feb 2026 16:39:06 +0900 Subject: [PATCH 04/25] feat: Enhance SelectedItemsDetailInputComponent with sourceKeyField auto-detection and FK mapping - Implemented automatic detection of sourceKeyField based on component configuration, improving flexibility in data handling. - Enhanced the SelectedItemsDetailInputConfigPanel to support automatic FK detection and mapping, streamlining the configuration process. - Updated the database connection logic to handle DATE types correctly, preventing timezone-related issues. - Improved overall component performance by optimizing memoization and state management for better user experience. --- backend-node/src/database/db.ts | 4 + .../SelectedItemsDetailInputComponent.tsx | 38 ++-- .../SelectedItemsDetailInputConfigPanel.tsx | 140 ++++++++++++- .../selected-items-detail-input/types.ts | 24 +++ scripts/browser-test-admin-switch-button.js | 170 +++++++++++++++ scripts/browser-test-customer-crud.js | 167 +++++++++++++++ scripts/browser-test-customer-via-menu.js | 157 ++++++++++++++ scripts/browser-test-purchase-supplier.js | 196 ++++++++++++++++++ 8 files changed, 865 insertions(+), 31 deletions(-) create mode 100644 scripts/browser-test-admin-switch-button.js create mode 100644 scripts/browser-test-customer-crud.js create mode 100644 scripts/browser-test-customer-via-menu.js create mode 100644 scripts/browser-test-purchase-supplier.js diff --git a/backend-node/src/database/db.ts b/backend-node/src/database/db.ts index 4c249ac3..6fc10cf1 100644 --- a/backend-node/src/database/db.ts +++ b/backend-node/src/database/db.ts @@ -13,9 +13,13 @@ import { PoolClient, QueryResult as PgQueryResult, QueryResultRow, + types, } from "pg"; import config from "../config/environment"; +// DATE 타입(OID 1082)을 문자열로 반환 (타임존 변환에 의한 -1day 버그 방지) +types.setTypeParser(1082, (val: string) => val); + // PostgreSQL 연결 풀 let pool: Pool | null = null; diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index c2bb436d..1f8b0484 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -67,9 +67,12 @@ export const SelectedItemsDetailInputComponent: React.FC { + // sourceKeyField는 config에서 직접 지정 (ConfigPanel 자동 감지에서 설정됨) + return componentConfig.sourceKeyField || "item_id"; + }, [componentConfig.sourceKeyField]); // 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id const dataSourceId = useMemo( @@ -446,10 +449,16 @@ export const SelectedItemsDetailInputComponent: React.FC = {}; + + // sourceKeyField 자동 매핑 (item_id = originalData.id) + if (sourceKeyField && item.originalData?.id) { + baseRecord[sourceKeyField] = item.originalData.id; + } + + // 나머지 autoFillFrom 필드 (sourceKeyField 제외) additionalFields.forEach((f) => { - if (f.autoFillFrom && item.originalData) { + if (f.name !== sourceKeyField && f.autoFillFrom && item.originalData) { const value = item.originalData[f.autoFillFrom]; if (value !== undefined && value !== null) { baseRecord[f.name] = value; @@ -504,7 +513,7 @@ export const SelectedItemsDetailInputComponent: React.FC { - const groupFields = additionalFields.filter((f) => f.groupId === group.id); - groupFields.forEach((field) => { - if (field.name === sourceKeyField && field.autoFillFrom && item.originalData) { - sourceKeyValue = item.originalData[field.autoFillFrom] || null; - } - }); - }); - } - - // 3순위: fallback (최후의 수단) + // 2순위: 원본 데이터의 id를 sourceKeyField 값으로 사용 (신규 등록 모드) if (!sourceKeyValue && item.originalData) { sourceKeyValue = item.originalData.id || null; } diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx index 61f755a4..1f70e7e0 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useMemo, useEffect } from "react"; +import React, { useState, useMemo, useEffect, useRef } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Card, CardContent } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Plus, X, ChevronDown, ChevronRight } from "lucide-react"; -import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat } from "./types"; +import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat, AutoDetectedFk } from "./types"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Check, ChevronsUpDown } from "lucide-react"; @@ -97,7 +97,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>([]); - const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); + const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); + + // FK 자동 감지 결과 + const [autoDetectedFks, setAutoDetectedFks] = useState([]); // 🆕 원본 테이블 컬럼 로드 useEffect(() => { @@ -130,10 +133,11 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { if (!config.targetTable) { setLoadedTargetTableColumns([]); + setAutoDetectedFks([]); return; } @@ -149,7 +153,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC(() => { + if (!config.targetTable || loadedTargetTableColumns.length === 0) return []; + + const entityFkColumns = loadedTargetTableColumns.filter( + (col) => col.inputType === "entity" && col.referenceTable + ); + if (entityFkColumns.length === 0) return []; + + return entityFkColumns.map((col) => { + let mappingType: "source" | "parent" | "unknown" = "unknown"; + if (config.sourceTable && col.referenceTable === config.sourceTable) { + mappingType = "source"; + } else if (config.sourceTable && col.referenceTable !== config.sourceTable) { + mappingType = "parent"; + } + return { + columnName: col.columnName, + columnLabel: col.columnLabel, + referenceTable: col.referenceTable!, + referenceColumn: col.referenceColumn || "id", + mappingType, + }; + }); + }, [config.targetTable, config.sourceTable, loadedTargetTableColumns]); + + // 감지 결과를 state에 반영 + useEffect(() => { + setAutoDetectedFks(detectedFks); + }, [detectedFks]); + + // 자동 매핑 적용 (최초 1회만, targetTable 변경 시 리셋) + useEffect(() => { + fkAutoAppliedRef.current = false; + }, [config.targetTable]); + + useEffect(() => { + if (fkAutoAppliedRef.current || detectedFks.length === 0) return; + + const sourceFk = detectedFks.find((fk) => fk.mappingType === "source"); + const parentFks = detectedFks.filter((fk) => fk.mappingType === "parent"); + let changed = false; + + // sourceKeyField 자동 설정 + if (sourceFk && !config.sourceKeyField) { + console.log("🔗 sourceKeyField 자동 설정:", sourceFk.columnName); + handleChange("sourceKeyField", sourceFk.columnName); + changed = true; + } + + // parentDataMapping 자동 생성 (기존에 없을 때만) + if (parentFks.length > 0 && (!config.parentDataMapping || config.parentDataMapping.length === 0)) { + const autoMappings = parentFks.map((fk) => ({ + sourceTable: fk.referenceTable, + sourceField: "id", + targetField: fk.columnName, + })); + console.log("🔗 parentDataMapping 자동 생성:", autoMappings); + handleChange("parentDataMapping", autoMappings); + changed = true; + } + + if (changed) { + fkAutoAppliedRef.current = true; + } + }, [detectedFks]); + // 🆕 필드 그룹 변경 시 로컬 입력 상태 동기화 useEffect(() => { setLocalFieldGroups(config.fieldGroups || []); @@ -898,6 +975,37 @@ export const SelectedItemsDetailInputConfigPanel: React.FC최종 데이터를 저장할 테이블

+ {/* FK 자동 감지 결과 표시 */} + {autoDetectedFks.length > 0 && ( +
+

+ FK 자동 감지됨 ({autoDetectedFks.length}건) +

+
+ {autoDetectedFks.map((fk) => ( +
+ + {fk.mappingType === "source" ? "원본" : fk.mappingType === "parent" ? "부모" : "미분류"} + + {fk.columnName} + -> + {fk.referenceTable} +
+ ))} +
+

+ 엔티티 설정 기반 자동 매핑. sourceKeyField와 parentDataMapping이 자동으로 설정됩니다. +

+
+ )} + {/* 표시할 원본 데이터 컬럼 */}
@@ -961,7 +1069,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - {localFields.map((field, index) => ( + {localFields.map((field, index) => { + return (
@@ -1255,7 +1364,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - ))} + ); + })} + +
+ + {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} + + {includeTime && viewMode === "calendar" && ( +
+ 시간: + handleTimeChange(e.target.value)} + className="border-input h-8 rounded-md border px-2 text-xs" + /> +
+ )} +
+ + + ); +}; diff --git a/frontend/components/screen/filters/InlineCellDatePicker.tsx b/frontend/components/screen/filters/InlineCellDatePicker.tsx new file mode 100644 index 00000000..f47546b4 --- /dev/null +++ b/frontend/components/screen/filters/InlineCellDatePicker.tsx @@ -0,0 +1,279 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; + +interface InlineCellDatePickerProps { + value: string; + onChange: (value: string) => void; + onSave: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + inputRef?: React.RefObject; +} + +export const InlineCellDatePicker: React.FC = ({ + value, + onChange, + onSave, + onKeyDown, + inputRef, +}) => { + const [isOpen, setIsOpen] = useState(true); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const localInputRef = useRef(null); + const actualInputRef = inputRef || localInputRef; + + const parseDate = (val: string): Date | undefined => { + if (!val) return undefined; + try { + const date = new Date(val); + if (isNaN(date.getTime())) return undefined; + return date; + } catch { + return undefined; + } + }; + + const selectedDate = parseDate(value); + + useEffect(() => { + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + } + }, []); + + const handleDateClick = (date: Date) => { + const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleSetToday = () => { + const today = new Date(); + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleClear = () => { + onChange(""); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleInputChange = (raw: string) => { + onChange(raw); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + } + } + }; + + const handlePopoverClose = (open: boolean) => { + if (!open) { + setIsOpen(false); + onSave(); + } + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + const startDate = new Date(monthStart); + const dayOfWeek = startDate.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; + + return ( + + + handleInputChange(e.target.value)} + onKeyDown={onKeyDown} + onClick={() => setIsOpen(true)} + placeholder="YYYYMMDD" + className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" + /> + + e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} +
+ + + ); +}; diff --git a/frontend/components/screen/filters/ModernDatePicker.tsx b/frontend/components/screen/filters/ModernDatePicker.tsx index 54fdcfed..79f16a41 100644 --- a/frontend/components/screen/filters/ModernDatePicker.tsx +++ b/frontend/components/screen/filters/ModernDatePicker.tsx @@ -34,6 +34,8 @@ export const ModernDatePicker: React.FC = ({ label, value const [isOpen, setIsOpen] = useState(false); const [currentMonth, setCurrentMonth] = useState(new Date()); const [selectingType, setSelectingType] = useState<"from" | "to">("from"); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); // 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장) const [tempValue, setTempValue] = useState(value || {}); @@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC = ({ label, value if (isOpen) { setTempValue(value || {}); setSelectingType("from"); + setViewMode("calendar"); } }, [isOpen, value]); @@ -234,60 +237,150 @@ export const ModernDatePicker: React.FC = ({ label, value
- {/* 월 네비게이션 */} -
- -
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
- -
- - {/* 요일 헤더 */} -
- {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( -
- {day} -
- ))} -
- - {/* 날짜 그리드 */} -
- {allDays.map((date, index) => { - if (!date) { - return
; - } - - const isCurrentMonth = isSameMonth(date, currentMonth); - const isSelected = isRangeStart(date) || isRangeEnd(date); - const isInRangeDate = isInRange(date); - const isTodayDate = isToday(date); - - return ( - - ); - })} -
+
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> + {/* 월 선택 뷰 */} +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> + {/* 월 네비게이션 */} +
+ + + +
+ + {/* 요일 헤더 */} +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ + {/* 날짜 그리드 */} +
+ {allDays.map((date, index) => { + if (!date) { + return
; + } + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = isRangeStart(date) || isRangeEnd(date); + const isInRangeDate = isInRange(date); + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} {/* 선택된 범위 표시 */} {(tempValue.from || tempValue.to) && ( diff --git a/frontend/components/screen/table-options/GroupingPanel.tsx b/frontend/components/screen/table-options/GroupingPanel.tsx index 0495991d..867448d0 100644 --- a/frontend/components/screen/table-options/GroupingPanel.tsx +++ b/frontend/components/screen/table-options/GroupingPanel.tsx @@ -99,7 +99,7 @@ export const GroupingPanel: React.FC = ({ 전체 해제
-
+
{selectedColumns.map((colName, index) => { const col = table?.columns.find( (c) => c.columnName === colName diff --git a/frontend/components/screen/table-options/TableSettingsModal.tsx b/frontend/components/screen/table-options/TableSettingsModal.tsx index ef07e017..4f9325cb 100644 --- a/frontend/components/screen/table-options/TableSettingsModal.tsx +++ b/frontend/components/screen/table-options/TableSettingsModal.tsx @@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC = ({ isOpen, onClose, onFilters 전체 해제
-
+
{selectedGroupColumns.map((colName, index) => { const col = table?.columns.find((c) => c.columnName === colName); if (!col) return null; diff --git a/frontend/components/screen/widgets/types/DateWidget.tsx b/frontend/components/screen/widgets/types/DateWidget.tsx index edb78df9..3b0f47e2 100644 --- a/frontend/components/screen/widgets/types/DateWidget.tsx +++ b/frontend/components/screen/widgets/types/DateWidget.tsx @@ -1,7 +1,22 @@ "use client"; -import React from "react"; -import { Input } from "@/components/ui/input"; +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; import { WebTypeComponentProps } from "@/lib/registry/types"; import { WidgetComponent, DateTypeConfig } from "@/types/screen"; @@ -10,99 +25,341 @@ export const DateWidget: React.FC = ({ component, value, const { placeholder, required, style } = widget; const config = widget.webTypeConfig as DateTypeConfig | undefined; - // 사용자가 테두리를 설정했는지 확인 const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border); const borderClass = hasCustomBorder ? "!border-0" : ""; - // 날짜 포맷팅 함수 - const formatDateValue = (val: string) => { - if (!val) return ""; + const isDatetime = widget.widgetType === "datetime"; + const [isOpen, setIsOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [timeValue, setTimeValue] = useState("00:00"); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + const parseDate = (val: string | undefined): Date | undefined => { + if (!val) return undefined; try { const date = new Date(val); - if (isNaN(date.getTime())) return val; - - if (widget.widgetType === "datetime") { - return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm - } else { - return date.toISOString().slice(0, 10); // YYYY-MM-DD - } + if (isNaN(date.getTime())) return undefined; + return date; } catch { - return val; + return undefined; } }; - // 날짜 유효성 검증 - const validateDate = (dateStr: string): boolean => { - if (!dateStr) return true; - - const date = new Date(dateStr); - if (isNaN(date.getTime())) return false; - - // 최소/최대 날짜 검증 - if (config?.minDate) { - const minDate = new Date(config.minDate); - if (date < minDate) return false; - } - - if (config?.maxDate) { - const maxDate = new Date(config.maxDate); - if (date > maxDate) return false; - } - - return true; - }; - - // 입력값 처리 - const handleChange = (e: React.ChangeEvent) => { - const inputValue = e.target.value; - - if (validateDate(inputValue)) { - onChange?.(inputValue); - } - }; - - // 웹타입에 따른 input type 결정 - const getInputType = () => { - switch (widget.widgetType) { - case "datetime": - return "datetime-local"; - case "date": - default: - return "date"; - } - }; - - // 기본값 설정 (현재 날짜/시간) - const getDefaultValue = () => { + const getDefaultValue = (): string => { if (config?.defaultValue === "current") { const now = new Date(); - if (widget.widgetType === "datetime") { - return now.toISOString().slice(0, 16); - } else { - return now.toISOString().slice(0, 10); - } + if (isDatetime) return now.toISOString().slice(0, 16); + return now.toISOString().slice(0, 10); } return ""; }; const finalValue = value || getDefaultValue(); + const selectedDate = parseDate(finalValue); + + useEffect(() => { + if (isOpen) { + setViewMode("calendar"); + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + if (isDatetime) { + const hours = String(selectedDate.getHours()).padStart(2, "0"); + const minutes = String(selectedDate.getMinutes()).padStart(2, "0"); + setTimeValue(`${hours}:${minutes}`); + } + } else { + setCurrentMonth(new Date()); + setTimeValue("00:00"); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [isOpen]); + + const formatDisplayValue = (): string => { + if (!selectedDate) return ""; + if (isDatetime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko }); + return format(selectedDate, "yyyy-MM-dd", { locale: ko }); + }; + + const handleDateClick = (date: Date) => { + let dateStr: string; + if (isDatetime) { + const [hours, minutes] = timeValue.split(":").map(Number); + const dt = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours || 0, minutes || 0); + dateStr = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}T${timeValue}`; + } else { + dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + } + onChange?.(dateStr); + if (!isDatetime) { + setIsOpen(false); + } + }; + + const handleTimeChange = (newTime: string) => { + setTimeValue(newTime); + if (selectedDate) { + const dateStr = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}T${newTime}`; + onChange?.(dateStr); + } + }; + + const handleClear = () => { + onChange?.(""); + setIsTyping(false); + setIsOpen(false); + }; + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!isOpen) setIsOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + let dateStr: string; + if (isDatetime) { + dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T${timeValue}`; + } else { + dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + } + onChange?.(dateStr); + setCurrentMonth(new Date(y, m, 1)); + if (!isDatetime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400); + else setIsTyping(false); + } + } + }; + + const handleSetToday = () => { + const today = new Date(); + if (isDatetime) { + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`; + onChange?.(dateStr); + } else { + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + onChange?.(dateStr); + } + setIsOpen(false); + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + const startDate = new Date(monthStart); + const dayOfWeek = startDate.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; return ( - + { if (!v) { setIsOpen(false); setIsTyping(false); } }}> + +
{ if (!readonly) setIsOpen(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!readonly && !isOpen) setIsOpen(true); }} + onBlur={() => { if (!isOpen) setIsTyping(false); }} + className="h-full w-full truncate bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> + {selectedDate && !readonly && !isTyping && ( + { + e.stopPropagation(); + handleClear(); + }} + /> + )} +
+
+ e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} + + {/* datetime 타입: 시간 입력 */} + {isDatetime && viewMode === "calendar" && ( +
+ 시간: + handleTimeChange(e.target.value)} + className="border-input h-8 rounded-md border px-2 text-xs" + /> +
+ )} + +
+ + ); }; DateWidget.displayName = "DateWidget"; - - diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index d6ed8c62..872e7d57 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useMemo } from "react"; import { apiClient } from "@/lib/api/client"; import { getCategoryValues } from "@/lib/api/tableCategoryValue"; -import { FolderTree, Loader2, Search, X } from "lucide-react"; +import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; interface CategoryColumn { @@ -30,6 +30,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, const [columns, setColumns] = useState([]); const [isLoading, setIsLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); // 검색어로 필터링된 컬럼 목록 const filteredColumns = useMemo(() => { @@ -49,6 +50,44 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, }); }, [columns, searchQuery]); + // 테이블별로 그룹화된 컬럼 목록 + const groupedColumns = useMemo(() => { + const groups: { tableName: string; tableLabel: string; columns: CategoryColumn[] }[] = []; + const groupMap = new Map(); + + for (const col of filteredColumns) { + const key = col.tableName; + if (!groupMap.has(key)) { + groupMap.set(key, []); + } + groupMap.get(key)!.push(col); + } + + for (const [tblName, cols] of groupMap) { + groups.push({ + tableName: tblName, + tableLabel: cols[0]?.tableLabel || tblName, + columns: cols, + }); + } + + return groups; + }, [filteredColumns]); + + // 선택된 컬럼이 있는 그룹을 자동 펼침 + useEffect(() => { + if (!selectedColumn) return; + const tableName = selectedColumn.split(".")[0]; + if (tableName) { + setExpandedGroups((prev) => { + if (prev.has(tableName)) return prev; + const next = new Set(prev); + next.add(tableName); + return next; + }); + } + }, [selectedColumn]); + useEffect(() => { // 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회 loadCategoryColumnsByMenu(); @@ -279,35 +318,114 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, )}
-
+
{filteredColumns.length === 0 && searchQuery ? (
'{searchQuery}'에 대한 검색 결과가 없습니다
) : null} - {filteredColumns.map((column) => { - const uniqueKey = `${column.tableName}.${column.columnName}`; - const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교 - return ( -
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} - className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ - isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" - }`} - > -
- -
-

{column.columnLabel || column.columnName}

-

{column.tableLabel || column.tableName}

-
- - {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} - + {groupedColumns.map((group) => { + const isExpanded = expandedGroups.has(group.tableName); + const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0); + const hasSelectedInGroup = group.columns.some( + (c) => selectedColumn === `${c.tableName}.${c.columnName}`, + ); + + // 그룹이 1개뿐이면 드롭다운 없이 바로 표시 + if (groupedColumns.length <= 1) { + return ( +
+ {group.columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + const isSelected = selectedColumn === uniqueKey; + return ( +
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} + className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ + isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" + }`} + > +
+ +
+

{column.columnLabel || column.columnName}

+

{column.tableLabel || column.tableName}

+
+ + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ); + })}
+ ); + } + + return ( +
+ {/* 드롭다운 헤더 */} + + + {/* 펼쳐진 컬럼 목록 */} + {isExpanded && ( +
+ {group.columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + const isSelected = selectedColumn === uniqueKey; + return ( +
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} + className={`cursor-pointer rounded-md px-3 py-1.5 transition-all ${ + isSelected ? "bg-primary/10 font-semibold text-primary" : "hover:bg-muted/50" + }`} + > +
+ + {column.columnLabel || column.columnName} + + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ); + })} +
+ )}
); })} diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index 3c7a9239..2da0647f 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( 2100) return null; + return date; +} + /** * 단일 날짜 선택 컴포넌트 */ const SingleDatePicker = forwardRef< - HTMLButtonElement, + HTMLDivElement, { value?: string; onChange?: (value: string) => void; @@ -83,80 +95,227 @@ const SingleDatePicker = forwardRef< ref, ) => { const [open, setOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + const inputRef = React.useRef(null); const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]); - const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); - const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); - // 표시할 날짜 텍스트 계산 (ISO 형식이면 포맷팅, 아니면 그대로) const displayText = useMemo(() => { if (!value) return ""; - // Date 객체로 변환 후 포맷팅 - if (date && isValid(date)) { - return formatDate(date, dateFormat); - } + if (date && isValid(date)) return formatDate(date, dateFormat); return value; }, [value, date, dateFormat]); - const handleSelect = useCallback( - (selectedDate: Date | undefined) => { - if (selectedDate) { - onChange?.(formatDate(selectedDate, dateFormat)); - setOpen(false); + useEffect(() => { + if (open) { + setViewMode("calendar"); + if (date && isValid(date)) { + setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1)); + setYearRangeStart(Math.floor(date.getFullYear() / 12) * 12); + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); } - }, - [dateFormat, onChange], - ); + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [open]); + + const handleDateClick = useCallback((clickedDate: Date) => { + onChange?.(formatDate(clickedDate, dateFormat)); + setIsTyping(false); + setOpen(false); + }, [dateFormat, onChange]); const handleToday = useCallback(() => { onChange?.(formatDate(new Date(), dateFormat)); + setIsTyping(false); setOpen(false); }, [dateFormat, onChange]); const handleClear = useCallback(() => { onChange?.(""); + setIsTyping(false); setOpen(false); }, [onChange]); + const handleTriggerInput = useCallback((raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!open) setOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const parsed = parseManualDateInput(digitsOnly); + if (parsed) { + onChange?.(formatDate(parsed, dateFormat)); + setCurrentMonth(new Date(parsed.getFullYear(), parsed.getMonth(), 1)); + setTimeout(() => { setIsTyping(false); setOpen(false); }, 400); + } + } + }, [dateFormat, onChange, open]); + + const mStart = startOfMonth(currentMonth); + const mEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: mStart, end: mEnd }); + const dow = mStart.getDay(); + const padding = dow === 0 ? 6 : dow - 1; + const allDays = [...Array(padding).fill(null), ...days]; + return ( - + { if (!v) { setOpen(false); setIsTyping(false); } }}> - + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readonly && !open) setOpen(true); }} + onBlur={() => { if (!open) setIsTyping(false); }} + className={cn( + "h-full w-full bg-transparent text-sm outline-none", + "placeholder:text-muted-foreground disabled:cursor-not-allowed", + !displayText && !isTyping && "text-muted-foreground", + )} + /> +
- - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - return false; - }} - /> -
- {showToday && ( - + )} + +
+ + {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+
+ {["월", "화", "수", "목", "금", "토", "일"].map((d) => ( +
{d}
+ ))} +
+
+ {allDays.map((d, idx) => { + if (!d) return
; + const isCur = isSameMonth(d, currentMonth); + const isSel = date ? isSameDay(d, date) : false; + const isT = isTodayFn(d); + return ( + + ); + })} +
+ )} -
@@ -168,6 +327,149 @@ SingleDatePicker.displayName = "SingleDatePicker"; /** * 날짜 범위 선택 컴포넌트 */ +/** + * 범위 날짜 팝오버 내부 캘린더 (drill-down 지원) + */ +const RangeCalendarPopover: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; + selectedDate?: Date; + onSelect: (date: Date) => void; + label: string; + disabled?: boolean; + readonly?: boolean; + displayValue?: string; +}> = ({ open, onOpenChange, selectedDate, onSelect, label, disabled, readonly, displayValue }) => { + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + useEffect(() => { + if (open) { + setViewMode("calendar"); + if (selectedDate && isValid(selectedDate)) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12); + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [open]); + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const parsed = parseManualDateInput(digitsOnly); + if (parsed) { + setIsTyping(false); + onSelect(parsed); + } + } + }; + + const mStart = startOfMonth(currentMonth); + const mEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: mStart, end: mEnd }); + const dow = mStart.getDay(); + const padding = dow === 0 ? 6 : dow - 1; + const allDays = [...Array(padding).fill(null), ...days]; + + return ( + { if (!v) { setIsTyping(false); } onOpenChange(v); }}> + +
{ if (!disabled && !readonly) onOpenChange(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readonly && !open) onOpenChange(true); }} + onBlur={() => { if (!open) setIsTyping(false); }} + className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> +
+
+ e.preventDefault()}> +
+ {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+
+ {["월", "화", "수", "목", "금", "토", "일"].map((d) => ( +
{d}
+ ))} +
+
+ {allDays.map((d, idx) => { + if (!d) return
; + const isCur = isSameMonth(d, currentMonth); + const isSel = selectedDate ? isSameDay(d, selectedDate) : false; + const isT = isTodayFn(d); + return ( + + ); + })} +
+ + )} +
+ + + ); +}; + const RangeDatePicker = forwardRef< HTMLDivElement, { @@ -186,102 +488,38 @@ const RangeDatePicker = forwardRef< const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]); const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]); - const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); - const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); const handleStartSelect = useCallback( - (date: Date | undefined) => { - if (date) { - const newStart = formatDate(date, dateFormat); - // 시작일이 종료일보다 크면 종료일도 같이 변경 - if (endDate && date > endDate) { - onChange?.([newStart, newStart]); - } else { - onChange?.([newStart, value[1]]); - } - setOpenStart(false); + (date: Date) => { + const newStart = formatDate(date, dateFormat); + if (endDate && date > endDate) { + onChange?.([newStart, newStart]); + } else { + onChange?.([newStart, value[1]]); } + setOpenStart(false); }, [value, dateFormat, endDate, onChange], ); const handleEndSelect = useCallback( - (date: Date | undefined) => { - if (date) { - const newEnd = formatDate(date, dateFormat); - // 종료일이 시작일보다 작으면 시작일도 같이 변경 - if (startDate && date < startDate) { - onChange?.([newEnd, newEnd]); - } else { - onChange?.([value[0], newEnd]); - } - setOpenEnd(false); + (date: Date) => { + const newEnd = formatDate(date, dateFormat); + if (startDate && date < startDate) { + onChange?.([newEnd, newEnd]); + } else { + onChange?.([value[0], newEnd]); } + setOpenEnd(false); }, [value, dateFormat, startDate, onChange], ); return (
- {/* 시작 날짜 */} - - - - - - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - return false; - }} - /> - - - + ~ - - {/* 종료 날짜 */} - - - - - - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - // 시작일보다 이전 날짜는 선택 불가 - if (startDate && date < startDate) return true; - return false; - }} - /> - - +
); }); diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 173a67ad..f753a240 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC = ({ )}
- {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} + {/* 확인 다이얼로그 */} - + {getConfirmTitle()} {getConfirmMessage()} diff --git a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx index 13a7ac4f..2f35c799 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx @@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC = ({ style, ...props }) => { - // 컴포넌트 설정 const componentConfig = { ...config, ...component.config, } as ImageDisplayConfig; - // 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) + const objectFit = componentConfig.objectFit || "contain"; + const altText = componentConfig.altText || "이미지"; + const borderRadius = componentConfig.borderRadius ?? 8; + const showBorder = componentConfig.showBorder ?? true; + const backgroundColor = componentConfig.backgroundColor || "#f9fafb"; + const placeholder = componentConfig.placeholder || "이미지 없음"; + + const imageSrc = component.value || componentConfig.imageUrl || ""; + const componentStyle: React.CSSProperties = { width: "100%", height: "100%", ...component.style, ...style, - // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) width: "100%", }; - // 디자인 모드 스타일 if (isDesignMode) { componentStyle.border = "1px dashed #cbd5e1"; componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; } - // 이벤트 핸들러 const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(); @@ -88,7 +92,9 @@ export const ImageDisplayComponent: React.FC = ({ }} > {component.label} - {component.required && *} + {(component.required || componentConfig.required) && ( + * + )} )} @@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC = ({ style={{ width: "100%", height: "100%", - border: "1px solid #d1d5db", - borderRadius: "8px", + border: showBorder ? "1px solid #d1d5db" : "none", + borderRadius: `${borderRadius}px`, overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center", - backgroundColor: "#f9fafb", + backgroundColor, transition: "all 0.2s ease-in-out", - boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)", + boxShadow: showBorder ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : "none", + opacity: componentConfig.disabled ? 0.5 : 1, + cursor: componentConfig.disabled ? "not-allowed" : "default", }} onMouseEnter={(e) => { - e.currentTarget.style.borderColor = "#f97316"; - e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)"; + if (!componentConfig.disabled) { + if (showBorder) { + e.currentTarget.style.borderColor = "#f97316"; + } + e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)"; + } }} onMouseLeave={(e) => { - e.currentTarget.style.borderColor = "#d1d5db"; - e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)"; + if (showBorder) { + e.currentTarget.style.borderColor = "#d1d5db"; + } + e.currentTarget.style.boxShadow = showBorder + ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" + : "none"; }} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd} > - {component.value || componentConfig.imageUrl ? ( + {imageSrc ? ( {componentConfig.altText { (e.target as HTMLImageElement).style.display = "none"; if (e.target?.parentElement) { e.target.parentElement.innerHTML = `
-
🖼️
+
이미지 로드 실패
`; @@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC = ({ fontSize: "14px", }} > -
🖼️
-
이미지 없음
+ + + + + +
{placeholder}
)}
@@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC = ({ /** * ImageDisplay 래퍼 컴포넌트 - * 추가적인 로직이나 상태 관리가 필요한 경우 사용 */ export const ImageDisplayWrapper: React.FC = (props) => { return ; diff --git a/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx b/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx index 6c73e1d9..7f36f51b 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx @@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types"; export interface ImageDisplayConfigPanelProps { config: ImageDisplayConfig; - onChange: (config: Partial) => void; + onChange?: (config: Partial) => void; + onConfigChange?: (config: Partial) => void; } /** * ImageDisplay 설정 패널 - * 컴포넌트의 설정값들을 편집할 수 있는 UI 제공 */ export const ImageDisplayConfigPanel: React.FC = ({ config, onChange, + onConfigChange, }) => { const handleChange = (key: keyof ImageDisplayConfig, value: any) => { - onChange({ [key]: value }); + const update = { ...config, [key]: value }; + onChange?.(update); + onConfigChange?.(update); }; return (
-
- image-display 설정 +
이미지 표시 설정
+ + {/* 이미지 URL */} +
+ + handleChange("imageUrl", e.target.value)} + placeholder="https://..." + className="h-8 text-xs" + /> +

+ 데이터 바인딩 값이 없을 때 표시할 기본 이미지 +

- {/* file 관련 설정 */} + {/* 대체 텍스트 */}
- + + handleChange("altText", e.target.value)} + placeholder="이미지 설명" + className="h-8 text-xs" + /> +
+ + {/* 이미지 맞춤 */} +
+ + +
+ + {/* 테두리 둥글기 */} +
+ + handleChange("borderRadius", parseInt(e.target.value) || 0)} + className="h-8 text-xs" + /> +
+ + {/* 배경 색상 */} +
+ +
+ handleChange("backgroundColor", e.target.value)} + className="h-8 w-8 cursor-pointer rounded border" + /> + handleChange("backgroundColor", e.target.value)} + className="h-8 flex-1 text-xs" + /> +
+
+ + {/* 플레이스홀더 */} +
+ handleChange("placeholder", e.target.value)} + placeholder="이미지 없음" + className="h-8 text-xs" />
- {/* 공통 설정 */} -
- + {/* 테두리 표시 */} +
handleChange("disabled", checked)} + id="showBorder" + checked={config.showBorder ?? true} + onCheckedChange={(checked) => handleChange("showBorder", checked)} /> +
-
- - handleChange("required", checked)} - /> -
- -
- + {/* 읽기 전용 */} +
handleChange("readonly", checked)} /> + +
+ + {/* 필수 입력 */} +
+ handleChange("required", checked)} + /> +
); diff --git a/frontend/lib/registry/components/image-display/config.ts b/frontend/lib/registry/components/image-display/config.ts index 268382f0..bae67e14 100644 --- a/frontend/lib/registry/components/image-display/config.ts +++ b/frontend/lib/registry/components/image-display/config.ts @@ -6,9 +6,14 @@ import { ImageDisplayConfig } from "./types"; * ImageDisplay 컴포넌트 기본 설정 */ export const ImageDisplayDefaultConfig: ImageDisplayConfig = { - placeholder: "입력하세요", - - // 공통 기본값 + imageUrl: "", + altText: "이미지", + objectFit: "contain", + borderRadius: 8, + showBorder: true, + backgroundColor: "#f9fafb", + placeholder: "이미지 없음", + disabled: false, required: false, readonly: false, @@ -18,23 +23,31 @@ export const ImageDisplayDefaultConfig: ImageDisplayConfig = { /** * ImageDisplay 컴포넌트 설정 스키마 - * 유효성 검사 및 타입 체크에 사용 */ export const ImageDisplayConfigSchema = { - placeholder: { type: "string", default: "" }, - - // 공통 스키마 + imageUrl: { type: "string", default: "" }, + altText: { type: "string", default: "이미지" }, + objectFit: { + type: "enum", + values: ["contain", "cover", "fill", "none", "scale-down"], + default: "contain", + }, + borderRadius: { type: "number", default: 8 }, + showBorder: { type: "boolean", default: true }, + backgroundColor: { type: "string", default: "#f9fafb" }, + placeholder: { type: "string", default: "이미지 없음" }, + disabled: { type: "boolean", default: false }, required: { type: "boolean", default: false }, readonly: { type: "boolean", default: false }, - variant: { - type: "enum", - values: ["default", "outlined", "filled"], - default: "default" + variant: { + type: "enum", + values: ["default", "outlined", "filled"], + default: "default", }, - size: { - type: "enum", - values: ["sm", "md", "lg"], - default: "md" + size: { + type: "enum", + values: ["sm", "md", "lg"], + default: "md", }, }; diff --git a/frontend/lib/registry/components/image-display/index.ts b/frontend/lib/registry/components/image-display/index.ts index ddb38f95..ffa5712a 100644 --- a/frontend/lib/registry/components/image-display/index.ts +++ b/frontend/lib/registry/components/image-display/index.ts @@ -21,7 +21,13 @@ export const ImageDisplayDefinition = createComponentDefinition({ webType: "file", component: ImageDisplayWrapper, defaultConfig: { - placeholder: "입력하세요", + imageUrl: "", + altText: "이미지", + objectFit: "contain", + borderRadius: 8, + showBorder: true, + backgroundColor: "#f9fafb", + placeholder: "이미지 없음", }, defaultSize: { width: 200, height: 200 }, configPanel: ImageDisplayConfigPanel, diff --git a/frontend/lib/registry/components/image-display/types.ts b/frontend/lib/registry/components/image-display/types.ts index f2b6971d..e882ebe4 100644 --- a/frontend/lib/registry/components/image-display/types.ts +++ b/frontend/lib/registry/components/image-display/types.ts @@ -6,20 +6,24 @@ import { ComponentConfig } from "@/types/component"; * ImageDisplay 컴포넌트 설정 타입 */ export interface ImageDisplayConfig extends ComponentConfig { - // file 관련 설정 + // 이미지 관련 설정 + imageUrl?: string; + altText?: string; + objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down"; + borderRadius?: number; + showBorder?: boolean; + backgroundColor?: string; placeholder?: string; - + // 공통 설정 disabled?: boolean; required?: boolean; readonly?: boolean; - placeholder?: string; - helperText?: string; - + // 스타일 관련 variant?: "default" | "outlined" | "filled"; size?: "sm" | "md" | "lg"; - + // 이벤트 관련 onChange?: (value: any) => void; onFocus?: () => void; @@ -37,7 +41,7 @@ export interface ImageDisplayProps { config?: ImageDisplayConfig; className?: string; style?: React.CSSProperties; - + // 이벤트 핸들러 onChange?: (value: any) => void; onFocus?: () => void; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index c806e0df..6d55b650 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -28,6 +28,7 @@ import { apiClient } from "@/lib/api/client"; import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { CascadingDropdownConfig } from "@/types/screen-management"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { UniversalFormModalComponentProps, @@ -1835,11 +1836,11 @@ export function UniversalFormModalComponent({ case "date": return ( - onChangeHandler(e.target.value)} + onChange={onChangeHandler} + placeholder={field.placeholder || "날짜를 선택하세요"} disabled={isDisabled} readOnly={field.readOnly} /> @@ -1847,13 +1848,14 @@ export function UniversalFormModalComponent({ case "datetime": return ( - onChangeHandler(e.target.value)} + onChange={onChangeHandler} + placeholder={field.placeholder || "날짜/시간을 선택하세요"} disabled={isDisabled} readOnly={field.readOnly} + includeTime /> ); diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 371814b5..c00c1b1f 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -1481,9 +1481,9 @@ export const ButtonPrimaryComponent: React.FC = ({ )}
- {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} + {/* 확인 다이얼로그 */} - + {getConfirmTitle()} {getConfirmMessage()} diff --git a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx index e8b0dba9..58554c9d 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx @@ -247,14 +247,12 @@ export const FileManagerModal: React.FC = ({
- {/* 파일 업로드 영역 - 높이 축소 */} - {!isDesignMode && ( + {/* 파일 업로드 영역 - readonly/disabled이면 숨김 */} + {!isDesignMode && !config.readonly && !config.disabled && (
{ - if (!config.disabled && !isDesignMode) { - fileInputRef.current?.click(); - } + fileInputRef.current?.click(); }} onDragOver={handleDragOver} onDragLeave={handleDragLeave} @@ -267,7 +265,6 @@ export const FileManagerModal: React.FC = ({ accept={config.accept} onChange={handleFileInputChange} className="hidden" - disabled={config.disabled} /> {uploading ? ( @@ -286,8 +283,8 @@ export const FileManagerModal: React.FC = ({ {/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
- {/* 좌측: 이미지 미리보기 (확대/축소 가능) */} -
+ {/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */} + {(config.showPreview !== false) &&
{/* 확대/축소 컨트롤 */} {selectedFile && previewImageUrl && (
@@ -369,10 +366,10 @@ export const FileManagerModal: React.FC = ({ {selectedFile.realFileName}
)} -
+
} - {/* 우측: 파일 목록 (고정 너비) */} -
+ {/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */} + {(config.showFileList !== false) &&

업로드된 파일

@@ -404,7 +401,7 @@ export const FileManagerModal: React.FC = ({ )}

- {formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()} + {config.showFileSize !== false && <>{formatFileSize(file.fileSize)} • }{file.fileExt.toUpperCase()}

@@ -434,19 +431,21 @@ export const FileManagerModal: React.FC = ({ > - - {!isDesignMode && ( + {config.allowDownload !== false && ( + + )} + {!isDesignMode && config.allowDelete !== false && (
)}
-
+
}
@@ -487,8 +486,8 @@ export const FileManagerModal: React.FC = ({ file={viewerFile} isOpen={isViewerOpen} onClose={handleViewerClose} - onDownload={onFileDownload} - onDelete={!isDesignMode ? onFileDelete : undefined} + onDownload={config.allowDownload !== false ? onFileDownload : undefined} + onDelete={!isDesignMode && config.allowDelete !== false ? onFileDelete : undefined} /> ); diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index fc39458a..de55bf2a 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -105,6 +105,8 @@ const FileUploadComponent: React.FC = ({ const [forceUpdate, setForceUpdate] = useState(0); const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); const fileInputRef = useRef(null); + // objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지) + const filesLoadedFromObjidRef = useRef(false); // 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리 const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); @@ -150,6 +152,7 @@ const FileUploadComponent: React.FC = ({ if (isRecordMode || !recordId) { setUploadedFiles([]); setRepresentativeImageUrl(null); + filesLoadedFromObjidRef.current = false; } } else if (prevIsRecordModeRef.current === null) { // 초기 마운트 시 모드 저장 @@ -191,63 +194,68 @@ const FileUploadComponent: React.FC = ({ }, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행 // 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드 - // 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지) + // 콤마로 구분된 다중 objid도 처리 (예: "123,456") const imageObjidFromFormData = formData?.[columnName]; useEffect(() => { - // 이미지 objid가 있고, 숫자 문자열인 경우에만 처리 - if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) { - const objidStr = String(imageObjidFromFormData); - - // 이미 같은 objid의 파일이 로드되어 있으면 스킵 - const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr); - if (alreadyLoaded) { - return; - } - - // 🔑 실제 파일 정보 조회 (previewUrl 제거 - apiClient blob 다운로드 방식으로 통일) - (async () => { - try { - const fileInfoResponse = await getFileInfoByObjid(objidStr); + if (!imageObjidFromFormData) return; + + const rawValue = String(imageObjidFromFormData); + // 콤마 구분 다중 objid 또는 단일 objid 모두 처리 + const objids = rawValue.split(',').map(s => s.trim()).filter(s => /^\d+$/.test(s)); + + if (objids.length === 0) return; + + // 모든 objid가 이미 로드되어 있으면 스킵 + const allLoaded = objids.every(id => uploadedFiles.some(f => String(f.objid) === id)); + if (allLoaded) return; + + (async () => { + try { + const loadedFiles: FileInfo[] = []; + + for (const objid of objids) { + // 이미 로드된 파일은 스킵 + if (uploadedFiles.some(f => String(f.objid) === objid)) continue; + + const fileInfoResponse = await getFileInfoByObjid(objid); if (fileInfoResponse.success && fileInfoResponse.data) { const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data; - const fileInfo = { - objid: objidStr, - realFileName: realFileName, - fileExt: fileExt, - fileSize: fileSize, - filePath: getFilePreviewUrl(objidStr), - regdate: regdate, + loadedFiles.push({ + objid, + realFileName, + fileExt, + fileSize, + filePath: getFilePreviewUrl(objid), + regdate, isImage: true, - isRepresentative: isRepresentative, - }; - - setUploadedFiles([fileInfo]); - // representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨 + isRepresentative, + } as FileInfo); } else { // 파일 정보 조회 실패 시 최소 정보로 추가 - console.warn("🖼️ [FileUploadComponent] 파일 정보 조회 실패, 최소 정보 사용"); - const minimalFileInfo = { - objid: objidStr, - realFileName: `image_${objidStr}.jpg`, + loadedFiles.push({ + objid, + realFileName: `file_${objid}`, fileExt: '.jpg', fileSize: 0, - filePath: getFilePreviewUrl(objidStr), + filePath: getFilePreviewUrl(objid), regdate: new Date().toISOString(), isImage: true, - }; - - setUploadedFiles([minimalFileInfo]); - // representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨 + } as FileInfo); } - } catch (error) { - console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); } - })(); - } - }, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존 + + if (loadedFiles.length > 0) { + setUploadedFiles(loadedFiles); + filesLoadedFromObjidRef.current = true; + } + } catch (error) { + console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); + } + })(); + }, [imageObjidFromFormData, columnName, component.id]); // 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너 // 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 @@ -365,6 +373,10 @@ const FileUploadComponent: React.FC = ({ ...file, })); + // 서버에서 0개 반환 + objid 기반 로딩이 이미 완료된 경우 덮어쓰지 않음 + if (formattedFiles.length === 0 && filesLoadedFromObjidRef.current) { + return false; + } // 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용) let finalFiles = formattedFiles; @@ -427,14 +439,19 @@ const FileUploadComponent: React.FC = ({ return; // DB 로드 성공 시 localStorage 무시 } - // 🆕 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지 + // objid 기반으로 이미 파일이 로드된 경우 빈 데이터로 덮어쓰지 않음 + if (filesLoadedFromObjidRef.current) { + return; + } + + // 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지 if (!isRecordMode || !recordId) { return; } // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) - // 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용) + // 전역 상태에서 최신 파일 정보 가져오기 (고유 키 사용) const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; const uniqueKeyForFallback = getUniqueKey(); const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || []; @@ -442,6 +459,10 @@ const FileUploadComponent: React.FC = ({ // 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성) const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles; + // 빈 데이터로 기존 파일을 덮어쓰지 않음 + if (currentFiles.length === 0) { + return; + } // 최신 파일과 현재 파일 비교 if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) { @@ -1147,8 +1168,8 @@ const FileUploadComponent: React.FC = ({ file={viewerFile} isOpen={isViewerOpen} onClose={handleViewerClose} - onDownload={handleFileDownload} - onDelete={!isDesignMode ? handleFileDelete : undefined} + onDownload={safeComponentConfig.allowDownload !== false ? handleFileDownload : undefined} + onDelete={!isDesignMode && safeComponentConfig.allowDelete !== false ? handleFileDelete : undefined} /> {/* 파일 관리 모달 */} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 30584fc4..ebdf9d2b 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2172,7 +2172,7 @@ export const TableListComponent: React.FC = ({ const handleRowClick = (row: any, index: number, e: React.MouseEvent) => { // 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨) const target = e.target as HTMLElement; - if (target.closest('input[type="checkbox"]')) { + if (target.closest('input[type="checkbox"]') || target.closest('button[role="checkbox"]')) { return; } @@ -2198,35 +2198,32 @@ export const TableListComponent: React.FC = ({ } }; - // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택) + // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글) const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { e.stopPropagation(); setFocusedCell({ rowIndex, colIndex }); - // 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용) tableContainerRef.current?.focus(); - // 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리 - // filteredData에서 해당 행의 데이터 가져오기 const row = filteredData[rowIndex]; if (!row) return; + // 체크박스 컬럼은 Checkbox의 onCheckedChange에서 이미 처리되므로 스킵 + const column = visibleColumns[colIndex]; + if (column?.columnName === "__checkbox__") return; + const rowKey = getRowKey(row, rowIndex); const isCurrentlySelected = selectedRows.has(rowKey); - // 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달 const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { - // 이미 선택된 행과 다른 행을 클릭한 경우에만 처리 + // 분할 패널 좌측: 단일 행 선택 모드 if (!isCurrentlySelected) { - // 기존 선택 해제하고 새 행 선택 setSelectedRows(new Set([rowKey])); setIsAllSelected(false); - // 분할 패널 컨텍스트에 데이터 저장 splitPanelContext.setSelectedLeftData(row); - // onSelectedRowsChange 콜백 호출 if (onSelectedRowsChange) { onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection); } @@ -2234,6 +2231,17 @@ export const TableListComponent: React.FC = ({ onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] }); } } + } else { + // 일반 모드: 행 선택/해제 토글 + handleRowSelection(rowKey, !isCurrentlySelected); + + if (splitPanelContext && effectiveSplitPosition === "left") { + if (!isCurrentlySelected) { + splitPanelContext.setSelectedLeftData(row); + } else { + splitPanelContext.setSelectedLeftData(null); + } + } } }; @@ -6309,6 +6317,21 @@ export const TableListComponent: React.FC = ({ ); } + // 날짜 타입: 캘린더 피커 + const isDateType = colMeta?.inputType === "date" || colMeta?.inputType === "datetime"; + if (isDateType) { + const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker"); + return ( + + ); + } + // 일반 입력 필드 return ( { if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) { return; @@ -450,26 +450,37 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table return; } - const newOptions: Record> = { ...selectOptions }; + const loadedOptions: Record> = {}; + let hasNewOptions = false; for (const filter of selectFilters) { - // 이미 로드된 옵션이 있으면 스킵 (초기값 유지) - if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) { - continue; - } - try { const options = await currentTable.getColumnUniqueValues(filter.columnName); - newOptions[filter.columnName] = options; + if (options && options.length > 0) { + loadedOptions[filter.columnName] = options; + hasNewOptions = true; + } } catch (error) { console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error); } } - setSelectOptions(newOptions); + + if (hasNewOptions) { + setSelectOptions((prev) => { + // 이미 로드된 옵션은 유지, 새로 로드된 옵션만 병합 + const merged = { ...prev }; + for (const [key, value] of Object.entries(loadedOptions)) { + if (!merged[key] || merged[key].length === 0) { + merged[key] = value; + } + } + return merged; + }); + } }; loadSelectOptions(); - }, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경 + }, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues, currentTable?.dataCount]); // 높이 변화 감지 및 알림 (실제 화면에서만) useEffect(() => { @@ -722,7 +733,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table - +
{uniqueOptions.length === 0 ? (
옵션 없음
@@ -739,7 +750,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)} onClick={(e) => e.stopPropagation()} /> - {option.label} + {option.label}
))}
From e622013b3d822c6a7316ef82dedec46d24d3f424 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 26 Feb 2026 17:32:39 +0900 Subject: [PATCH 08/25] feat: Enhance image handling in TableCellImage component - Updated the TableCellImage component to support multiple image inputs, displaying a representative image when available. - Implemented a new helper function `loadImageBlob` for loading images from blob URLs, improving image loading efficiency. - Refactored image loading logic to handle both single and multiple objid cases, ensuring robust error handling and loading states. - Enhanced user experience by allowing direct URL usage for non-objid image paths. --- .../v2-table-list/TableListComponent.tsx | 120 ++++++++++++------ 1 file changed, 81 insertions(+), 39 deletions(-) diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index ebdf9d2b..4170360d 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -14,53 +14,71 @@ import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; // 🖼️ 테이블 셀 이미지 썸네일 컴포넌트 // objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용 +// 다중 이미지(콤마 구분)인 경우 대표 이미지를 우선 표시 const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { const [imgSrc, setImgSrc] = React.useState(null); + const [displayObjid, setDisplayObjid] = React.useState(""); const [error, setError] = React.useState(false); const [loading, setLoading] = React.useState(true); React.useEffect(() => { let mounted = true; - // 다중 이미지인 경우 대표 이미지(첫 번째)만 사용 const rawValue = String(value); - const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue; - const isObjid = /^\d+$/.test(strValue); + const parts = rawValue.split(",").map(s => s.trim()).filter(Boolean); - if (isObjid) { - // objid인 경우: 인증된 API로 blob 다운로드 - const loadImage = async () => { - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.get(`/files/preview/${strValue}`, { - responseType: "blob", - }); - if (mounted) { - const blob = new Blob([response.data]); - const url = window.URL.createObjectURL(blob); - setImgSrc(url); - setLoading(false); - } - } catch { - if (mounted) { - setError(true); - setLoading(false); - } - } - }; - loadImage(); - } else { - // 경로인 경우: 직접 URL 사용 - setImgSrc(getFullImageUrl(strValue)); - setLoading(false); + // 단일 값 또는 경로인 경우 + if (parts.length <= 1) { + const strValue = parts[0] || rawValue; + setDisplayObjid(strValue); + const isObjid = /^\d+$/.test(strValue); + + if (isObjid) { + loadImageBlob(strValue, mounted, setImgSrc, setError, setLoading); + } else { + setImgSrc(getFullImageUrl(strValue)); + setLoading(false); + } + return () => { mounted = false; }; } - return () => { - mounted = false; - // blob URL 해제 - if (imgSrc && imgSrc.startsWith("blob:")) { - window.URL.revokeObjectURL(imgSrc); + // 다중 objid: 대표 이미지를 찾아서 표시 + const objids = parts.filter(s => /^\d+$/.test(s)); + if (objids.length === 0) { + setLoading(false); + setError(true); + return () => { mounted = false; }; + } + + (async () => { + try { + const { getFileInfoByObjid } = await import("@/lib/api/file"); + let representativeId: string | null = null; + + // 각 objid의 대표 여부를 확인 + for (const objid of objids) { + const info = await getFileInfoByObjid(objid); + if (info.success && info.data?.isRepresentative) { + representativeId = objid; + break; + } + } + + // 대표 이미지가 없으면 첫 번째 사용 + const targetObjid = representativeId || objids[0]; + if (mounted) { + setDisplayObjid(targetObjid); + loadImageBlob(targetObjid, mounted, setImgSrc, setError, setLoading); + } + } catch { + if (mounted) { + // 대표 조회 실패 시 첫 번째 사용 + setDisplayObjid(objids[0]); + loadImageBlob(objids[0], mounted, setImgSrc, setError, setLoading); + } } - }; + })(); + + return () => { mounted = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); @@ -91,10 +109,8 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { style={{ maxWidth: "40px", maxHeight: "40px" }} onClick={(e) => { e.stopPropagation(); - const rawValue = String(value); - const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue; - const isObjid = /^\d+$/.test(strValue); - const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue); + const isObjid = /^\d+$/.test(displayObjid); + const openUrl = isObjid ? getFilePreviewUrl(displayObjid) : getFullImageUrl(displayObjid); window.open(openUrl, "_blank"); }} onError={() => setError(true)} @@ -104,6 +120,32 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { }); TableCellImage.displayName = "TableCellImage"; +// 이미지 blob 로딩 헬퍼 +function loadImageBlob( + objid: string, + mounted: boolean, + setImgSrc: (url: string) => void, + setError: (err: boolean) => void, + setLoading: (loading: boolean) => void, +) { + import("@/lib/api/client").then(({ apiClient }) => { + apiClient.get(`/files/preview/${objid}`, { responseType: "blob" }) + .then((response) => { + if (mounted) { + const blob = new Blob([response.data]); + setImgSrc(window.URL.createObjectURL(blob)); + setLoading(false); + } + }) + .catch(() => { + if (mounted) { + setError(true); + setLoading(false); + } + }); + }); +} + // 🆕 RelatedDataButtons 전역 레지스트리 타입 선언 declare global { interface Window { From 385a10e2e77a61596b8c14dbcca6acd477f17948 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 26 Feb 2026 20:48:56 +0900 Subject: [PATCH 09/25] feat: Add BOM version initialization feature and enhance version handling - Implemented a new endpoint to initialize BOM versions, automatically creating the first version and updating related details. - Enhanced the BOM service to include logic for version name handling and duplication checks during version creation. - Updated the BOM controller to support the new initialization functionality, improving BOM management capabilities. - Improved the BOM version modal to allow users to specify version names during creation, enhancing user experience and flexibility. --- backend-node/src/controllers/bomController.ts | 18 ++- backend-node/src/routes/bomRoutes.ts | 1 + backend-node/src/services/bomService.ts | 90 ++++++++++-- .../BomItemEditorComponent.tsx | 129 +++++++++++------- .../v2-bom-tree/BomDetailEditModal.tsx | 55 +++++++- .../v2-bom-tree/BomTreeComponent.tsx | 40 +++++- .../v2-bom-tree/BomVersionModal.tsx | 70 ++++++++-- 7 files changed, 321 insertions(+), 82 deletions(-) diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts index 8355b148..3508fca4 100644 --- a/backend-node/src/controllers/bomController.ts +++ b/backend-node/src/controllers/bomController.ts @@ -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,20 @@ 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 }); + } +} + export async function deleteBomVersion(req: Request, res: Response) { try { const { bomId, versionId } = req.params; diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts index f6e3ee62..4aa8838d 100644 --- a/backend-node/src/routes/bomRoutes.ts +++ b/backend-node/src/routes/bomRoutes.ts @@ -20,6 +20,7 @@ router.post("/:bomId/history", bomController.addBomHistory); // 버전 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); diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index 89da38a9..b5cff246 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -98,6 +98,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 +108,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`, - [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; + // 버전명: 사용자 입력 > 순번 자동 생성 + let versionName = inputVersionName?.trim(); + if (!versionName) { + const countResult = await client.query( + `SELECT COUNT(*)::int as cnt FROM ${vTable} WHERE bom_id = $1`, + [bomId], + ); + 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 +257,68 @@ 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 }; + }); +} + /** * 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제 */ diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index e4521ac0..75c1909b 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -86,6 +86,7 @@ interface ItemSearchModalProps { onClose: () => void; onSelect: (items: ItemInfo[]) => void; companyCode?: string; + existingItemIds?: Set; } function ItemSearchModal({ @@ -93,6 +94,7 @@ function ItemSearchModal({ onClose, onSelect, companyCode, + existingItemIds, }: ItemSearchModalProps) { const [searchText, setSearchText] = useState(""); const [items, setItems] = useState([]); @@ -182,7 +184,7 @@ function ItemSearchModal({
) : (
- + - {items.map((item) => ( - { - setSelectedItems((prev) => { - const next = new Set(prev); - if (next.has(item.id)) next.delete(item.id); - else next.add(item.id); - return next; - }); - }} - className={cn( - "cursor-pointer border-t transition-colors", - selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent", - )} - > - - - - - - - ))} + {items.map((item) => { + const alreadyAdded = existingItemIds?.has(item.id) || false; + return ( + { + if (alreadyAdded) return; + setSelectedItems((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); + else next.add(item.id); + return next; + }); + }} + className={cn( + "border-t transition-colors", + alreadyAdded + ? "cursor-not-allowed opacity-40" + : "cursor-pointer", + !alreadyAdded && selectedItems.has(item.id) ? "bg-primary/10" : !alreadyAdded ? "hover:bg-accent" : "", + )} + > + + + + + + + ); + })}
e.stopPropagation()}> - { - setSelectedItems((prev) => { - const next = new Set(prev); - if (checked) next.add(item.id); - else next.delete(item.id); - return next; - }); - }} - /> - - {item.item_number} - {item.item_name}{item.type}{item.unit}
e.stopPropagation()}> + { + if (alreadyAdded) return; + setSelectedItems((prev) => { + const next = new Set(prev); + if (checked) next.add(item.id); + else next.delete(item.id); + return next; + }); + }} + /> + + {item.item_number} + {alreadyAdded && (추가됨)} + {item.item_name}{item.type}{item.unit}
)} @@ -739,37 +751,40 @@ export function BomItemEditorComponent({ [originalNotifyChange, markChanged], ); + const handleSaveAllRef = React.useRef<(() => Promise) | 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) | 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[] = []; @@ -1338,6 +1353,18 @@ export function BomItemEditorComponent({ onClose={() => setItemSearchOpen(false)} onSelect={handleItemSelect} companyCode={companyCode} + existingItemIds={useMemo(() => { + const ids = new Set(); + 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])} />
); diff --git a/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx index cfff4a0c..6b5d4a40 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx @@ -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>({}); 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) { @@ -67,11 +88,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); } @@ -139,12 +164,28 @@ export function BomDetailEditModal({
- handleChange("process_type", e.target.value)} - placeholder="예: 조립공정" - className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" - /> + {processOptions.length > 0 ? ( + + ) : ( + handleChange("process_type", e.target.value)} + placeholder="예: 조립공정" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> + )}
diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 957b8d85..5234a74d 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -138,6 +138,23 @@ export function BomTreeComponent({ const showHistory = features.showHistory !== false; const showVersion = features.showVersion !== false; + // 카테고리 라벨 캐시 (process_type 등) + const [categoryLabels, setCategoryLabels] = useState>>({}); + 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 = {}; + vals.forEach((v: any) => { map[v.value_code] = v.value_label; }); + setCategoryLabels((prev) => ({ ...prev, process_type: map })); + } + } catch { /* 무시 */ } + }; + loadLabels(); + }, [detailTable]); + // ─── 데이터 로드 ─── // BOM 헤더 데이터로 가상 0레벨 루트 노드 생성 @@ -168,7 +185,18 @@ export function BomTreeComponent({ setLoading(true); try { const searchFilter: Record = { [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; } @@ -461,6 +489,11 @@ export function BomTreeComponent({ return {value || "-"}; } + if (col.key === "status") { + const statusMap: Record = { active: "사용", inactive: "미사용", developing: "개발중" }; + return {statusMap[String(value)] || value || "-"}; + } + if (col.key === "quantity" || col.key === "base_qty") { return ( @@ -469,6 +502,11 @@ export function BomTreeComponent({ ); } + if (col.key === "process_type" && value) { + const label = categoryLabels.process_type?.[String(value)] || String(value); + return {label}; + } + if (col.key === "loss_rate") { const num = Number(value); if (!num) return -; diff --git a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx index d36bfe6e..48c27cc9 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx @@ -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(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 )}
+ {showNewInput && ( +
+ 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 + /> + + +
+ )} + - + {!showNewInput && ( + + )} + )} + {(componentConfig.leftPanel?.showDelete !== false) && ( + + )} +
+ + )} ); })} @@ -3429,6 +3465,10 @@ export const SplitPanelLayoutComponent: React.FC } // 🔧 일반 테이블 렌더링 (그룹화 없음) + const hasLeftTableActions = !isDesignMode && ( + (componentConfig.leftPanel?.showEdit !== false) || + (componentConfig.leftPanel?.showDelete !== false) + ); return (
@@ -3447,6 +3487,10 @@ export const SplitPanelLayoutComponent: React.FC {col.label} ))} + {hasLeftTableActions && ( + + )} @@ -3461,7 +3505,7 @@ export const SplitPanelLayoutComponent: React.FC 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 +3523,34 @@ export const SplitPanelLayoutComponent: React.FC )} ))} + {hasLeftTableActions && ( + + )} ); })} From 708a0fbd1f50856614520458c707da0d56ae30ed Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 26 Feb 2026 20:55:15 +0900 Subject: [PATCH 11/25] Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node From bfc89501ba4e3182d30d8baf2eeb001750ccc498 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 07:33:54 +0900 Subject: [PATCH 12/25] feat: Enhance BOM and UI components with improved label handling and data mapping - Updated the BOM service to include additional fields in the BOM header retrieval, enhancing data richness. - Enhanced the EditModal to automatically map foreign key fields to dot notation, improving data handling and user experience. - Improved the rendering of labels in various components, allowing for customizable label positions and styles, enhancing UI flexibility. - Added new properties for label positioning and spacing in the V2 component styles, allowing for better layout control. - Enhanced the BomTreeComponent to support additional data mapping for entity joins, improving data accessibility and management. --- backend-node/src/services/bomService.ts | 5 +- frontend/components/screen/EditModal.tsx | 21 +++- .../EnhancedInteractiveScreenViewer.tsx | 53 ++++++--- .../screen/InteractiveScreenViewer.tsx | 53 +++++++-- .../screen/InteractiveScreenViewerDynamic.tsx | 62 +++++++++-- .../screen/panels/V2PropertiesPanel.tsx | 59 ++++++++-- frontend/components/v2/V2Date.tsx | 71 ++++++++---- frontend/components/v2/V2Input.tsx | 101 ++++++++++-------- frontend/components/v2/V2Select.tsx | 94 ++++++++++------ .../v2-bom-tree/BomDetailEditModal.tsx | 20 ++-- .../v2-bom-tree/BomTreeComponent.tsx | 13 +++ frontend/types/v2-core.ts | 2 + 12 files changed, 409 insertions(+), 145 deletions(-) diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index b5cff246..f1d6fd84 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -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 diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 8dad77db..41c51d85 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -274,7 +274,26 @@ export const EditModal: React.FC = ({ 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 || {}); diff --git a/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx b/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx index f1acae0b..d0a99d91 100644 --- a/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx +++ b/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx @@ -245,23 +245,29 @@ export const EnhancedInteractiveScreenViewer: React.FC { if (hideLabel) return null; - const labelStyle = widget.style || {}; + const ls = widget.style || {}; const labelElement = (
+
+
+ {(componentConfig.leftPanel?.showEdit !== false) && ( + + )} + {(componentConfig.leftPanel?.showDelete !== false) && ( + + )} +
+
+ + + + + + + + + + + + + + + {parsedRows.map((row) => ( + + + + + + + + + + + + ))} + +
#구분레벨품번품명소요량단위공정비고
{row.rowIndex} + {row.isHeader ? ( + + {isVersionMode ? "건너뜀" : "마스터"} + + ) : row.valid ? ( + + ) : ( + + + + )} + + + {row.level} + + {row.item_number}{row.item_name}{row.quantity}{row.unit}{row.process_type}{row.remark}
+
+ + {invalidCount > 0 && ( +
+
유효하지 않은 행 ({invalidCount}건)
+
    + {parsedRows.filter(r => !r.valid).slice(0, 5).map(r => ( +
  • {r.rowIndex}행: {r.error}
  • + ))} + {invalidCount > 5 &&
  • ...외 {invalidCount - 5}건
  • } +
+
+ )} + +
+ {isVersionMode + ? "레벨 1 이상의 하위품목으로 새 버전을 생성합니다." + : "레벨 0 품목으로 새 BOM 마스터를 생성하고, 레벨 1 이상은 하위품목으로 등록합니다." + } +
+
+ )} + + {/* Step 3: 결과 */} + {step === "result" && uploadResult && ( +
+
+
+ +
+

+ {isVersionMode ? "새 버전 생성 완료" : "BOM 생성 완료"} +

+

+ 하위품목 {uploadResult.insertedCount}건이 등록되었습니다. +

+
+ +
+ {!isVersionMode && ( +
+
1
+
BOM 마스터
+
+ )} +
+
{uploadResult.insertedCount}
+
하위품목
+
+
+
+ )} + + + {step === "upload" && ( + + )} + {step === "preview" && ( + <> + + + + )} + {step === "result" && ( + + )} + + +
+ ); +} diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 5234a74d..1aede9de 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -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(null); const [historyModalOpen, setHistoryModalOpen] = useState(false); const [versionModalOpen, setVersionModalOpen] = useState(false); + const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [colWidths, setColWidths] = useState>({}); const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => { @@ -824,6 +827,15 @@ export function BomTreeComponent({ 버전 )} +
); } diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 427f2da5..5a839620 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -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 const [showAddModal, setShowAddModal] = useState(false); const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null); const [addModalFormData, setAddModalFormData] = useState>({}); + const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false); // 수정 모달 상태 const [showEditModal, setShowEditModal] = useState(false); @@ -3010,12 +3013,20 @@ export const SplitPanelLayoutComponent: React.FC {componentConfig.leftPanel?.title || "좌측 패널"} - {!isDesignMode && componentConfig.leftPanel?.showAdd && ( - - )} +
+ {!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && ( + + )} + {!isDesignMode && componentConfig.leftPanel?.showAdd && ( + + )} +
{componentConfig.leftPanel?.showSearch && ( @@ -5070,6 +5081,16 @@ export const SplitPanelLayoutComponent: React.FC
+ + {(componentConfig.leftPanel as any)?.showBomExcelUpload && ( + { + loadLeftData(); + }} + /> + )} ); }; From 4e997ae36b37a8ce3a35d6473de257a934e9c480 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 08:48:21 +0900 Subject: [PATCH 14/25] feat: Enhance V2Select component with automatic value normalization and update handling - Implemented automatic normalization of legacy plain text values to category codes, improving data consistency. - Added logic to handle comma-separated values, allowing for better processing of complex input formats. - Integrated automatic updates to the onChange handler when the normalized value differs from the original, ensuring accurate data saving. - Updated various select components to utilize the resolved value for consistent behavior across different selection types. --- frontend/components/v2/V2Select.tsx | 52 ++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index bd81c3ca..a7769227 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -820,6 +820,42 @@ export const V2Select = forwardRef( loadOptions(); }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); + // 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응) + 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 []; @@ -945,7 +981,7 @@ export const V2Select = forwardRef( return ( ( return ( handleChangeWithAutoFill(v)} disabled={isDisabled} /> @@ -972,7 +1008,7 @@ export const V2Select = forwardRef( return ( ( return ( ( return ( ( return ( handleChangeWithAutoFill(v)} disabled={isDisabled} /> @@ -1017,7 +1053,7 @@ export const V2Select = forwardRef( return ( ( return ( Date: Fri, 27 Feb 2026 11:01:22 +0900 Subject: [PATCH 15/25] refactor: Hide selected rows information in TableListComponent - Removed the display of selected rows count and the deselect button from the TableListComponent. - Updated the comment to indicate that the selected information is now hidden, improving code clarity and maintainability. --- .../lib/registry/DynamicComponentRenderer.tsx | 3 ++- .../v2-table-list/TableListComponent.tsx | 24 ++++++------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 72af2a34..88eaf946 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -356,9 +356,10 @@ export const DynamicComponentRenderer: React.FC = // 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") && diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 4170360d..717ea6ef 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2243,6 +2243,12 @@ export const TableListComponent: React.FC = ({ // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글) 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 = ({ )} - {/* 선택 정보 */} - {selectedRows.size > 0 && ( -
- - {selectedRows.size}개 선택됨 - - -
- )} + {/* 선택 정보 - 숨김 처리 */} {/* 🆕 통합 검색 패널 */} {(tableConfig.toolbar?.showSearch ?? false) && ( From 0f52c3adc28b7619ace1b68bdb9261ef1a9c0fef Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 27 Feb 2026 11:46:43 +0900 Subject: [PATCH 16/25] refactor: Improve V2Repeater integration and event handling - Updated the EditModal component to check for registered V2Repeater instances before saving detail data, enhancing the reliability of the repeater save process. - Simplified the V2Repeater component by removing unnecessary groupedData handling, ensuring it manages its own data effectively. - Enhanced the DynamicComponentRenderer to correctly handle V2Repeater's data management, improving overall component behavior. - Refactored button actions to wait for V2Repeater save completion only when active repeaters are present, optimizing performance and user experience. --- frontend/components/screen/EditModal.tsx | 114 +++++++++--------- frontend/components/v2/V2Repeater.tsx | 104 ++-------------- .../lib/registry/DynamicComponentRenderer.tsx | 6 +- frontend/lib/utils/buttonActions.ts | 93 +++++++------- 4 files changed, 118 insertions(+), 199 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 442c51cb..1f4d4dcc 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -1202,38 +1202,35 @@ export const EditModal: React.FC = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } - // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) - try { - const repeaterSavePromise = new Promise((resolve) => { - const fallbackTimeout = setTimeout(resolve, 5000); - const handler = () => { - clearTimeout(fallbackTimeout); - window.removeEventListener("repeaterSaveComplete", handler); - resolve(); - }; - window.addEventListener("repeaterSaveComplete", handler); - }); + // V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만) + const hasRepeaterForInsert = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; + if (hasRepeaterForInsert) { + try { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); - console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", { - parentId: masterRecordId, - tableName: screenData.screenInfo.tableName, - }); + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: masterRecordId, + tableName: screenData.screenInfo.tableName, + mainFormData: formData, + masterRecordId, + }, + }), + ); - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: masterRecordId, - tableName: screenData.screenInfo.tableName, - mainFormData: formData, - masterRecordId, - }, - }), - ); - - await repeaterSavePromise; - console.log("✅ [EditModal] INSERT 후 repeaterSave 완료"); - } catch (repeaterError) { - console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + await repeaterSavePromise; + } catch (repeaterError) { + console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + } } handleClose(); @@ -1332,38 +1329,35 @@ export const EditModal: React.FC = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } - // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) - try { - const repeaterSavePromise = new Promise((resolve) => { - const fallbackTimeout = setTimeout(resolve, 5000); - const handler = () => { - clearTimeout(fallbackTimeout); - window.removeEventListener("repeaterSaveComplete", handler); - resolve(); - }; - window.addEventListener("repeaterSaveComplete", handler); - }); + // V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만) + const hasRepeaterForUpdate = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; + if (hasRepeaterForUpdate) { + try { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); - console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", { - parentId: recordId, - tableName: screenData.screenInfo.tableName, - }); + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: recordId, + tableName: screenData.screenInfo.tableName, + mainFormData: formData, + masterRecordId: recordId, + }, + }), + ); - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: recordId, - tableName: screenData.screenInfo.tableName, - mainFormData: formData, - masterRecordId: recordId, - }, - }), - ); - - await repeaterSavePromise; - console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료"); - } catch (repeaterError) { - console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + await repeaterSavePromise; + } catch (repeaterError) { + console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + } } // 리피터 저장 완료 후 메인 테이블 새로고침 diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index 8b769b56..b60617e6 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -50,9 +50,6 @@ export const V2Repeater: React.FC = ({ 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 = ({ 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); + if (!window.__v2RepeaterInstances) { + window.__v2RepeaterInstances = new Set(); } + 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]); @@ -968,90 +964,8 @@ export const V2Repeater: React.FC = ({ [], ); - // 모달에서 전달된 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(); - 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; - 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(() => { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 88eaf946..85532c36 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -546,10 +546,12 @@ export const DynamicComponentRenderer: React.FC = 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] || ""; } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 390404ce..7f8514ab 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1893,29 +1893,34 @@ export class ButtonActionExecutor { mainFormDataKeys: Object.keys(mainFormData), }); - // V2Repeater 저장 완료를 기다리기 위한 Promise - const repeaterSavePromise = new Promise((resolve) => { - const fallbackTimeout = setTimeout(resolve, 5000); - const handler = () => { - clearTimeout(fallbackTimeout); - window.removeEventListener("repeaterSaveComplete", handler); - resolve(); - }; - window.addEventListener("repeaterSaveComplete", handler); - }); + // V2Repeater가 등록된 경우에만 저장 완료를 기다림 + // @ts-ignore + const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: savedId, - tableName: context.tableName, - mainFormData, - masterRecordId: savedId, - }, - }), - ); + if (hasActiveRepeaters) { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); - await repeaterSavePromise; + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: savedId, + tableName: context.tableName, + mainFormData, + masterRecordId: savedId, + }, + }), + ); + + await repeaterSavePromise; + } // 테이블과 플로우 새로고침 (모달 닫기 전에 실행) context.onRefresh?.(); @@ -1951,29 +1956,33 @@ export class ButtonActionExecutor { formDataKeys: Object.keys(formData), }); - const repeaterSavePromise = new Promise((resolve) => { - const fallbackTimeout = setTimeout(resolve, 5000); - const handler = () => { - clearTimeout(fallbackTimeout); - window.removeEventListener("repeaterSaveComplete", handler); - resolve(); - }; - window.addEventListener("repeaterSaveComplete", handler); - }); + // @ts-ignore + const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: savedId, - tableName: context.tableName, - mainFormData: formData, - masterRecordId: savedId, - }, - }), - ); + if (hasActiveRepeaters) { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); - await repeaterSavePromise; - console.log("✅ [dispatchRepeaterSave] repeaterSave 완료"); + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: savedId, + tableName: context.tableName, + mainFormData: formData, + masterRecordId: savedId, + }, + }), + ); + + await repeaterSavePromise; + } } /** From d686c385e00b891fc84261aae4e58ae8b70a03e6 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 11:57:21 +0900 Subject: [PATCH 17/25] feat: Implement edit mode detection in SelectedItemsDetailInputComponent - Added logic to detect edit mode based on URL parameters and existing data IDs. - Enhanced value retrieval for form fields to prioritize original data in edit mode, ensuring accurate updates. - Removed redundant edit mode detection comments to streamline the code and improve clarity. --- .../SelectedItemsDetailInputComponent.tsx | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index c2be4bb4..ff94b8dc 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -568,6 +568,12 @@ export const SelectedItemsDetailInputComponent: React.FC !!item.originalData?.id); + const isEditMode = urlEditMode || dataHasDbId; + // 부모 키 추출 (parentDataMapping에서) const parentKeys: Record = {}; @@ -581,16 +587,25 @@ export const SelectedItemsDetailInputComponent: React.FC { - // 1차: formData(sourceData)에서 찾기 - let value = getFieldValue(sourceData, mapping.sourceField); + let value: any; - // 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) { - const registryItem = registryData[0].originalData || registryData[0]; - value = registryItem[mapping.sourceField]; + // 수정 모드: originalData의 targetField 값 우선 사용 + // 로드(editFilters)와 동일한 방식으로 FK 값을 가져와야 + // 백엔드에서 기존 레코드를 정확히 매칭하여 UPDATE 수행 가능 + if (isEditMode && items.length > 0 && items[0].originalData) { + value = items[0].originalData[mapping.targetField]; + } + + // 신규 모드 또는 originalData에 값 없으면 기존 로직 + if (value === undefined || value === null) { + value = getFieldValue(sourceData, mapping.sourceField); + + if ((value === undefined || value === null) && mapping.sourceTable) { + const registryData = dataRegistry[mapping.sourceTable]; + if (registryData && registryData.length > 0) { + const registryItem = registryData[0].originalData || registryData[0]; + value = registryItem[mapping.sourceField]; + } } } @@ -646,15 +661,6 @@ export const SelectedItemsDetailInputComponent: React.FC !!item.originalData?.id); - const isEditMode = urlEditMode || dataHasDbId; - console.log("[SelectedItemsDetailInput] 수정 모드 감지:", { urlEditMode, dataHasDbId, From c1f7f27005952d0f950a872fb005dd7be7c7fbb1 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 27 Feb 2026 12:06:49 +0900 Subject: [PATCH 18/25] fix: Improve option filtering in V2Select component - Updated the option filtering logic to handle null and undefined values, preventing potential crashes when cmdk encounters these values. - Introduced a safeOptions variable to ensure that only valid options are processed in the dropdown and command list. - Enhanced the setOptions function to sanitize fetched options, ensuring that only valid values are set, improving overall stability and user experience. --- frontend/components/v2/V2Select.tsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index f0021eeb..bdc1ae24 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -80,7 +80,7 @@ const DropdownSelect = forwardRef {options - .filter((option) => option.value !== "") + .filter((option) => option.value != null && option.value !== "") .map((option) => ( {option.label} @@ -112,6 +112,12 @@ const DropdownSelect = forwardRef + 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 { 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 { 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 검색 결과가 없습니다. - {options.map((option) => { + {safeOptions.map((option) => { const displayLabel = option.label || option.value || "(빈 값)"; return ( ( } } - 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); From 8bfc2ba4f57f2d7dfb7eb212d3351ea8c1986b72 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 27 Feb 2026 13:00:22 +0900 Subject: [PATCH 19/25] feat: Enhance dynamic form service to handle VIEW tables - Introduced a new method `resolveBaseTable` to determine the original table name for VIEWs, allowing for seamless data operations. - Updated existing methods (`saveFormData`, `updateFormDataPartial`, `updateFormData`, and `deleteFormData`) to utilize `resolveBaseTable`, ensuring that operations are performed on the correct base table. - Improved logging to provide clearer insights into the operations being performed, including handling of original table names when dealing with VIEWs. --- .../src/services/dynamicFormService.ts | 71 ++++++++++++++++--- .../components/common/ExcelUploadModal.tsx | 45 ++++++++---- 2 files changed, 95 insertions(+), 21 deletions(-) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index e1242afd..7383e02b 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -210,19 +210,62 @@ export class DynamicFormService { } } + /** + * VIEW인 경우 원본(base) 테이블명을 반환, 일반 테이블이면 그대로 반환 + */ + async resolveBaseTable(tableName: string): Promise { + 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, ipAddress?: string ): Promise { + // 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, newData: Record ): Promise { + // 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 ): Promise { + // VIEW인 경우 원본 테이블로 전환 + const tableName = await this.resolveBaseTable(tableNameInput); try { console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", { id, tableName, + originalTable: tableNameInput !== tableName ? tableNameInput : undefined, data, }); @@ -1212,9 +1261,13 @@ export class DynamicFormService { screenId?: number ): Promise { try { + // VIEW인 경우 원본 테이블로 전환 (VIEW에는 기본키가 없으므로) + const actualTable = await this.resolveBaseTable(tableName); + console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { id, - tableName, + tableName: actualTable, + originalTable: tableName !== actualTable ? tableName : undefined, }); // 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회 @@ -1232,15 +1285,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 +1325,7 @@ export class DynamicFormService { // 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성 const deleteQuery = ` - DELETE FROM ${tableName} + DELETE FROM ${actualTable} WHERE ${primaryKeyColumn} = $1${typeCastSuffix} RETURNING * `; @@ -1292,7 +1345,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); diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 81b5ed61..f3c2ff2d 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -903,7 +903,8 @@ export const ExcelUploadModal: React.FC = ({ } } - 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 = ({ 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 = ({ } // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용 - 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 = ({ 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 = ({ } } + 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 = ({ message += `중복 건너뛰기 ${skipCount}개`; } if (failCount > 0) { - message += ` (실패: ${failCount}개)`; + message += `, 실패 ${failCount}개`; } - toast.success(message); + if (failCount > 0 && successCount === 0) { + toast.warning(message); + } else { + toast.success(message); + } // 매핑 템플릿 저장 await saveMappingTemplateInternal(); - onSuccess?.(); + if (successCount > 0 || overwriteCount > 0) { + onSuccess?.(); + } + } else if (failCount > 0) { + toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`); } else { toast.error("업로드에 실패했습니다."); } From 649bd77bbb8c10709d88dfb5740d77dac0eb3b36 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 13:09:20 +0900 Subject: [PATCH 20/25] feat: Enhance dynamic form and BOM item editor functionality - Added support for updating the `updated_date` field in the DynamicFormService, ensuring accurate timestamp management. - Refactored the BomItemEditorComponent to improve data handling by filtering valid fields before saving, enhancing data integrity. - Introduced a mechanism to track existing item IDs to prevent duplicates during item addition, improving user experience and data consistency. - Streamlined the save process in ButtonActionExecutor by reorganizing the event handling logic, ensuring better integration with EditModal components. --- .../src/services/dynamicFormService.ts | 3 + bom-save-console-logs.txt | 271 ++++++++++++++++++ .../BomItemEditorComponent.tsx | 52 ++-- frontend/lib/utils/buttonActions.ts | 42 +-- 4 files changed, 315 insertions(+), 53 deletions(-) create mode 100644 bom-save-console-logs.txt diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index e1242afd..4c24e206 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1033,6 +1033,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(); } diff --git a/bom-save-console-logs.txt b/bom-save-console-logs.txt new file mode 100644 index 00000000..f962f536 --- /dev/null +++ b/bom-save-console-logs.txt @@ -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 \ No newline at end of file diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index 75c1909b..fcb7b710 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -812,7 +812,7 @@ export function BomItemEditorComponent({ : null; if (node._isNew) { - const payload: Record = { + const raw: Record = { ...node.data, [fkColumn]: bomId, [parentKeyColumn]: realParentId, @@ -821,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 = {}; + 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`, @@ -835,17 +841,14 @@ export function BomItemEditorComponent({ savedCount++; } else if (node.id) { const updatedData: Record = { - ...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( @@ -934,6 +937,20 @@ export function BomItemEditorComponent({ setItemSearchOpen(true); }, []); + // 이미 추가된 품목 ID 목록 (중복 방지용) + const existingItemIds = useMemo(() => { + const ids = new Set(); + 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); @@ -1353,18 +1370,7 @@ export function BomItemEditorComponent({ onClose={() => setItemSearchOpen(false)} onSelect={handleItemSelect} companyCode={companyCode} - existingItemIds={useMemo(() => { - const ids = new Set(); - 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])} + existingItemIds={existingItemIds} /> ); diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 7f8514ab..054b257f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -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 이벤트를 발행하고 완료를 기다림 From 1a6d78df43ab75f1c545060df84aa2c93405ab03 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 13:30:57 +0900 Subject: [PATCH 21/25] refactor: Improve existing item ID handling in BomItemEditorComponent - Updated the logic for tracking existing item IDs to prevent duplicates during item addition, ensuring that sibling items are checked for duplicates at the same level while allowing duplicates in child levels. - Enhanced the existingItemIds calculation to differentiate between root level and child level additions, improving data integrity and user experience. - Refactored the useMemo hook to include addTargetParentId as a dependency, ensuring accurate updates when the target parent ID changes. --- .../BomItemEditorComponent.tsx | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index fcb7b710..bd5f3d92 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -937,19 +937,38 @@ export function BomItemEditorComponent({ setItemSearchOpen(true); }, []); - // 이미 추가된 품목 ID 목록 (중복 방지용) + // 같은 레벨(형제) 품목 ID 목록 (동일 레벨 중복 방지, 하위 레벨은 허용) const existingItemIds = useMemo(() => { const ids = new Set(); - const collect = (nodes: BomItemNode[]) => { - for (const n of nodes) { - const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"]; + const fkField = cfg.dataSource?.foreignKey || "child_item_id"; + + if (addTargetParentId === null) { + // 루트 레벨 추가: 루트 노드의 형제들만 체크 + for (const n of treeData) { + const fk = n.data[fkField]; if (fk) ids.add(fk); - collect(n.children); } - }; - collect(treeData); + } else { + // 하위 추가: 해당 부모의 직속 자식들만 체크 + const findParent = (nodes: BomItemNode[]): BomItemNode | null => { + for (const n of nodes) { + if (n.tempId === addTargetParentId) return n; + const found = findParent(n.children); + if (found) return found; + } + return null; + }; + const parent = findParent(treeData); + if (parent) { + for (const child of parent.children) { + const fk = child.data[fkField]; + if (fk) ids.add(fk); + } + } + } + return ids; - }, [treeData, cfg]); + }, [treeData, cfg, addTargetParentId]); // 루트 품목 추가 시작 const handleAddRoot = useCallback(() => { From 21c0c2b95c347e48870e43977f37d550bd43e3be Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 14:00:06 +0900 Subject: [PATCH 22/25] fix: Enhance layout loading logic in screen management - Updated the ScreenManagementService to allow SUPER_ADMIN or users with companyCode as "*" to load layouts based on the screen's company code. - Improved layout loading in ScreenViewPage and EditModal components by implementing fallback mechanisms to ensure a valid layout is always set. - Added console warnings for better debugging when layout loading fails, enhancing error visibility and user experience. - Refactored label display logic in various components to ensure consistent behavior across input types. --- .../src/services/screenManagementService.ts | 4 +- .../app/(main)/screens/[screenId]/page.tsx | 20 ++- frontend/components/screen/EditModal.tsx | 23 +++- .../screen/InteractiveScreenViewer.tsx | 7 +- .../screen/InteractiveScreenViewerDynamic.tsx | 2 +- frontend/components/v2/V2Date.tsx | 2 +- frontend/components/v2/V2Input.tsx | 2 +- frontend/components/v2/V2Select.tsx | 2 +- .../table-list/TableListConfigPanel.tsx | 124 +++++++++++++++++- .../v2-table-list/TableListConfigPanel.tsx | 124 ++++++++++++++++++ frontend/lib/utils/buttonActions.ts | 14 +- 11 files changed, 304 insertions(+), 20 deletions(-) diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 6f412de5..74506a39 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5083,8 +5083,8 @@ export class ScreenManagementService { let layout: { layout_data: any } | null = null; // 🆕 기본 레이어(layer_id=1)를 우선 로드 - // SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회 - if (isSuperAdmin) { + // SUPER_ADMIN이거나 companyCode가 "*"인 경우: 화면의 회사 코드로 레이아웃 조회 + if (isSuperAdmin || companyCode === "*") { // 1. 화면 정의의 회사 코드 + 기본 레이어 layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 160883ad..d1e07abe 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -179,7 +179,25 @@ function ScreenViewPage() { } else { // V1 레이아웃 또는 빈 레이아웃 const layoutData = await screenApi.getLayout(screenId); - setLayout(layoutData); + if (layoutData?.components?.length > 0) { + setLayout(layoutData); + } else { + console.warn("[ScreenViewPage] getLayout 실패, getLayerLayout(1) fallback:", screenId); + const baseLayerData = await screenApi.getLayerLayout(screenId, 1); + if (baseLayerData && isValidV2Layout(baseLayerData)) { + const converted = convertV2ToLegacy(baseLayerData); + if (converted) { + setLayout({ + ...converted, + screenResolution: baseLayerData.screenResolution || converted.screenResolution, + } as LayoutData); + } else { + setLayout(layoutData); + } + } else { + setLayout(layoutData); + } + } } } catch (layoutError) { console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError); diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index fe6ba4fa..ec36096d 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -413,9 +413,28 @@ export const EditModal: React.FC = ({ className }) => { // V2 없으면 기존 API fallback if (!layoutData) { + console.warn("[EditModal] V2 레이아웃 없음, getLayout fallback 시도:", screenId); layoutData = await screenApi.getLayout(screenId); } + // getLayout도 실패하면 기본 레이어(layer_id=1) 직접 로드 + if (!layoutData || !layoutData.components || layoutData.components.length === 0) { + console.warn("[EditModal] getLayout도 실패, getLayerLayout(1) 최종 fallback:", screenId); + try { + const baseLayerData = await screenApi.getLayerLayout(screenId, 1); + if (baseLayerData && isValidV2Layout(baseLayerData)) { + layoutData = convertV2ToLegacy(baseLayerData); + if (layoutData) { + layoutData.screenResolution = baseLayerData.screenResolution || layoutData.screenResolution; + } + } else if (baseLayerData?.components) { + layoutData = baseLayerData; + } + } catch (fallbackErr) { + console.error("[EditModal] getLayerLayout(1) fallback 실패:", fallbackErr); + } + } + if (screenInfo && layoutData) { const components = layoutData.components || []; @@ -1440,7 +1459,7 @@ export const EditModal: React.FC = ({ className }) => { -
+
{loading ? (
@@ -1455,7 +1474,7 @@ export const EditModal: React.FC = ({ className }) => { >
= ( // 라벨 표시 여부 계산 const shouldShowLabel = - !hideLabel && // hideLabel이 true면 라벨 숨김 - (component.style?.labelDisplay ?? true) && + !hideLabel && + (component.style?.labelDisplay ?? true) !== false && + component.style?.labelDisplay !== "false" && (component.label || component.style?.labelText) && - !templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함 + !templateTypes.includes(component.type); const labelText = component.style?.labelText || component.label || ""; diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index a35c5ed2..253c886d 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1109,7 +1109,7 @@ export const InteractiveScreenViewerDynamic: React.FC((props, ref) => { } }; - const showLabel = label && style?.labelDisplay !== false; + const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false"; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index d76802e8..219fa275 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -962,7 +962,7 @@ export const V2Input = forwardRef((props, ref) => }; const actualLabel = label || style?.labelText; - const showLabel = actualLabel && style?.labelDisplay === true; + const showLabel = actualLabel && style?.labelDisplay !== false && style?.labelDisplay !== "false"; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index e7dbfd86..690791d5 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -1135,7 +1135,7 @@ export const V2Select = forwardRef( } }; - const showLabel = label && style?.labelDisplay !== false; + const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false"; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 8cd8b0c5..f3a28c4c 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -10,7 +10,7 @@ import { TableListConfig, ColumnConfig } from "./types"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; -import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2 } from "lucide-react"; +import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, Pencil } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { cn } from "@/lib/utils"; @@ -1213,6 +1213,34 @@ export const TableListConfigPanel: React.FC = ({ )} + {/* 선택된 컬럼 순서 변경 */} + {config.columns && config.columns.length > 0 && ( +
+
+

컬럼 순서 / 설정

+

+ 선택된 컬럼의 순서를 변경하거나 표시명을 수정할 수 있습니다 +

+
+
+
+ {[...(config.columns || [])] + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((column, idx) => ( + moveColumn(column.columnName, direction)} + onRemove={() => removeColumn(column.columnName)} + onUpdate={(updates) => updateColumn(column.columnName, updates)} + /> + ))} +
+
+ )} + {/* 🆕 데이터 필터링 설정 */}
@@ -1240,3 +1268,97 @@ export const TableListConfigPanel: React.FC = ({
); }; + +/** + * 선택된 컬럼 항목 컴포넌트 + * 순서 이동, 삭제, 표시명 수정 기능 제공 + */ +const SelectedColumnItem: React.FC<{ + column: ColumnConfig; + index: number; + total: number; + onMove: (direction: "up" | "down") => void; + onRemove: () => void; + onUpdate: (updates: Partial) => void; +}> = ({ column, index, total, onMove, onRemove, onUpdate }) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(column.displayName || column.columnName); + + const handleSave = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== column.displayName) { + onUpdate({ displayName: trimmed }); + } + setIsEditing(false); + }; + + return ( +
+ + + {index + 1} + + {isEditing ? ( + setEditValue(e.target.value)} + onBlur={handleSave} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") { + setEditValue(column.displayName || column.columnName); + setIsEditing(false); + } + }} + className="h-5 flex-1 px-1 text-xs" + autoFocus + /> + ) : ( + + )} + +
+ + + +
+
+ ); +}; diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index 35f15596..ad250a16 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -22,6 +22,8 @@ import { Database, Table2, Link2, + GripVertical, + Pencil, } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; @@ -1458,6 +1460,34 @@ export const TableListConfigPanel: React.FC = ({ )} + {/* 선택된 컬럼 순서 변경 */} + {config.columns && config.columns.length > 0 && ( +
+
+

컬럼 순서 / 설정

+

+ 선택된 컬럼의 순서를 변경하거나 표시명을 수정할 수 있습니다 +

+
+
+
+ {[...(config.columns || [])] + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((column, idx) => ( + moveColumn(column.columnName, direction)} + onRemove={() => removeColumn(column.columnName)} + onUpdate={(updates) => updateColumn(column.columnName, updates)} + /> + ))} +
+
+ )} + {/* 🆕 데이터 필터링 설정 */}
@@ -1484,3 +1514,97 @@ export const TableListConfigPanel: React.FC = ({
); }; + +/** + * 선택된 컬럼 항목 컴포넌트 + * 순서 이동, 삭제, 표시명 수정 기능 제공 + */ +const SelectedColumnItem: React.FC<{ + column: ColumnConfig; + index: number; + total: number; + onMove: (direction: "up" | "down") => void; + onRemove: () => void; + onUpdate: (updates: Partial) => void; +}> = ({ column, index, total, onMove, onRemove, onUpdate }) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(column.displayName || column.columnName); + + const handleSave = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== column.displayName) { + onUpdate({ displayName: trimmed }); + } + setIsEditing(false); + }; + + return ( +
+ + + {index + 1} + + {isEditing ? ( + setEditValue(e.target.value)} + onBlur={handleSave} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") { + setEditValue(column.displayName || column.columnName); + setIsEditing(false); + } + }} + className="h-5 flex-1 px-1 text-xs" + autoFocus + /> + ) : ( + + )} + +
+ + + +
+
+ ); +}; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 054b257f..2ed4db87 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -3173,16 +3173,16 @@ export class ButtonActionExecutor { return false; } - // 1. 화면 설명 가져오기 - let description = config.modalDescription || ""; - if (!description) { + // 1. 화면 정보 가져오기 (제목/설명이 미설정 시 화면명에서 가져옴) + let screenInfo: any = null; + if (!config.modalTitle || !config.modalDescription) { try { - const screenInfo = await screenApi.getScreen(config.targetScreenId); - description = screenInfo?.description || ""; + screenInfo = await screenApi.getScreen(config.targetScreenId); } catch (error) { - console.warn("화면 설명을 가져오지 못했습니다:", error); + console.warn("화면 정보를 가져오지 못했습니다:", error); } } + let description = config.modalDescription || screenInfo?.description || ""; // 2. 데이터 소스 및 선택된 데이터 수집 let selectedData: any[] = []; @@ -3288,7 +3288,7 @@ export class ButtonActionExecutor { } // 3. 동적 모달 제목 생성 - let finalTitle = config.modalTitle || "화면"; + let finalTitle = config.modalTitle || screenInfo?.screenName || "데이터 등록"; // 블록 기반 제목 처리 if (config.modalTitleBlocks?.length) { From 026e99511cce366bb0e543868709792190db6996 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 14:30:31 +0900 Subject: [PATCH 23/25] refactor: Enhance label display and drag-and-drop functionality in table configuration - Updated the InteractiveScreenViewer and InteractiveScreenViewerDynamic components to include label positioning and size adjustments based on horizontal label settings. - Improved the DynamicComponentRenderer to handle label display logic more robustly, allowing for string values in addition to boolean. - Introduced drag-and-drop functionality in the TableListConfigPanel for reordering selected columns, enhancing user experience and flexibility in column management. - Refactored the display name resolution logic to prioritize available column labels, ensuring accurate representation in the UI. --- .../screen/InteractiveScreenViewer.tsx | 11 +- .../screen/InteractiveScreenViewerDynamic.tsx | 17 +- .../lib/registry/DynamicComponentRenderer.tsx | 4 +- .../table-list/TableListConfigPanel.tsx | 228 +++++++++-------- .../v2-table-list/TableListConfigPanel.tsx | 229 +++++++++--------- 5 files changed, 255 insertions(+), 234 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 1d64b597..7a9a3ff3 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -2233,8 +2233,17 @@ export const InteractiveScreenViewer: React.FC = ( ...component, style: { ...component.style, - labelDisplay: false, // 상위에서 라벨을 표시했으므로 컴포넌트 내부에서는 숨김 + labelDisplay: false, + labelPosition: "top" as const, + ...(isHorizontalLabel ? { width: "100%", height: "100%" } : {}), }, + ...(isHorizontalLabel ? { + size: { + ...component.size, + width: undefined as unknown as number, + height: undefined as unknown as number, + }, + } : {}), } : component; diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 253c886d..bcf9959c 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1292,7 +1292,22 @@ export const InteractiveScreenViewerDynamic: React.FC = componentType === "modal-repeater-table" || componentType === "v2-input"; - // 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시) + // 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시) const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay; - const effectiveLabel = labelDisplay === true + const effectiveLabel = (labelDisplay === true || labelDisplay === "true") ? (component.style?.labelText || (component as any).label || component.componentConfig?.label) : undefined; diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index f3a28c4c..8526b0c9 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -10,11 +10,74 @@ import { TableListConfig, ColumnConfig } from "./types"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; -import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, Pencil } from "lucide-react"; +import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, X } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { cn } from "@/lib/utils"; import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; +import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +/** + * 드래그 가능한 선택된 컬럼 행 (v2-split-panel-layout의 SortableColumnRow 동일 패턴) + */ +function SortableColumnRow({ + id, + col, + index, + isEntityJoin, + onLabelChange, + onWidthChange, + onRemove, +}: { + id: string; + col: ColumnConfig; + index: number; + isEntityJoin?: boolean; + onLabelChange: (value: string) => void; + onWidthChange: (value: number) => void; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const style = { transform: CSS.Transform.toString(transform), transition }; + + return ( +
+
+ +
+ {isEntityJoin ? ( + + ) : ( + #{index + 1} + )} + onLabelChange(e.target.value)} + placeholder="표시명" + className="h-6 min-w-0 flex-1 text-xs" + /> + onWidthChange(parseInt(e.target.value) || 100)} + placeholder="너비" + className="h-6 w-14 shrink-0 text-xs" + /> + +
+ ); +} export interface TableListConfigPanelProps { config: TableListConfig; @@ -348,11 +411,11 @@ export const TableListConfigPanel: React.FC = ({ const existingColumn = config.columns?.find((col) => col.columnName === columnName); if (existingColumn) return; - // tableColumns에서 해당 컬럼의 라벨 정보 찾기 + // tableColumns → availableColumns 순서로 한국어 라벨 찾기 const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName); + const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName); - // 라벨명 우선 사용, 없으면 컬럼명 사용 - const displayName = columnInfo?.label || columnInfo?.displayName || columnName; + const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName; const newColumn: ColumnConfig = { columnName, @@ -1213,31 +1276,59 @@ export const TableListConfigPanel: React.FC = ({ )} - {/* 선택된 컬럼 순서 변경 */} + {/* 선택된 컬럼 순서 변경 (DnD) */} {config.columns && config.columns.length > 0 && (
-

컬럼 순서 / 설정

+

표시할 컬럼 ({config.columns.length}개 선택)

- 선택된 컬럼의 순서를 변경하거나 표시명을 수정할 수 있습니다 + 드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다


-
- {[...(config.columns || [])] - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map((column, idx) => ( - moveColumn(column.columnName, direction)} - onRemove={() => removeColumn(column.columnName)} - onUpdate={(updates) => updateColumn(column.columnName, updates)} - /> - ))} -
+ { + const { active, over } = event; + if (!over || active.id === over.id) return; + const columns = [...(config.columns || [])]; + const oldIndex = columns.findIndex((c) => c.columnName === active.id); + const newIndex = columns.findIndex((c) => c.columnName === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + const reordered = arrayMove(columns, oldIndex, newIndex); + reordered.forEach((col, idx) => { col.order = idx; }); + handleChange("columns", reordered); + } + }} + > + c.columnName)} + strategy={verticalListSortingStrategy} + > +
+ {(config.columns || []).map((column, idx) => { + const resolvedLabel = + column.displayName && column.displayName !== column.columnName + ? column.displayName + : availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName; + + const colWithLabel = { ...column, displayName: resolvedLabel }; + return ( + updateColumn(column.columnName, { displayName: value })} + onWidthChange={(value) => updateColumn(column.columnName, { width: value })} + onRemove={() => removeColumn(column.columnName)} + /> + ); + })} +
+
+
)} @@ -1269,96 +1360,3 @@ export const TableListConfigPanel: React.FC = ({ ); }; -/** - * 선택된 컬럼 항목 컴포넌트 - * 순서 이동, 삭제, 표시명 수정 기능 제공 - */ -const SelectedColumnItem: React.FC<{ - column: ColumnConfig; - index: number; - total: number; - onMove: (direction: "up" | "down") => void; - onRemove: () => void; - onUpdate: (updates: Partial) => void; -}> = ({ column, index, total, onMove, onRemove, onUpdate }) => { - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(column.displayName || column.columnName); - - const handleSave = () => { - const trimmed = editValue.trim(); - if (trimmed && trimmed !== column.displayName) { - onUpdate({ displayName: trimmed }); - } - setIsEditing(false); - }; - - return ( -
- - - {index + 1} - - {isEditing ? ( - setEditValue(e.target.value)} - onBlur={handleSave} - onKeyDown={(e) => { - if (e.key === "Enter") handleSave(); - if (e.key === "Escape") { - setEditValue(column.displayName || column.columnName); - setIsEditing(false); - } - }} - className="h-5 flex-1 px-1 text-xs" - autoFocus - /> - ) : ( - - )} - -
- - - -
-
- ); -}; diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index ad250a16..7de8a533 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -23,12 +23,75 @@ import { Table2, Link2, GripVertical, - Pencil, + X, } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { cn } from "@/lib/utils"; import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel"; +import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +/** + * 드래그 가능한 선택된 컬럼 행 (v2-split-panel-layout의 SortableColumnRow 동일 패턴) + */ +function SortableColumnRow({ + id, + col, + index, + isEntityJoin, + onLabelChange, + onWidthChange, + onRemove, +}: { + id: string; + col: ColumnConfig; + index: number; + isEntityJoin?: boolean; + onLabelChange: (value: string) => void; + onWidthChange: (value: number) => void; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); + const style = { transform: CSS.Transform.toString(transform), transition }; + + return ( +
+
+ +
+ {isEntityJoin ? ( + + ) : ( + #{index + 1} + )} + onLabelChange(e.target.value)} + placeholder="표시명" + className="h-6 min-w-0 flex-1 text-xs" + /> + onWidthChange(parseInt(e.target.value) || 100)} + placeholder="너비" + className="h-6 w-14 shrink-0 text-xs" + /> + +
+ ); +} export interface TableListConfigPanelProps { config: TableListConfig; @@ -368,11 +431,11 @@ export const TableListConfigPanel: React.FC = ({ const existingColumn = config.columns?.find((col) => col.columnName === columnName); if (existingColumn) return; - // tableColumns에서 해당 컬럼의 라벨 정보 찾기 + // tableColumns → availableColumns 순서로 한국어 라벨 찾기 const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName); + const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName); - // 라벨명 우선 사용, 없으면 컬럼명 사용 - const displayName = columnInfo?.label || columnInfo?.displayName || columnName; + const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName; const newColumn: ColumnConfig = { columnName, @@ -1460,31 +1523,60 @@ export const TableListConfigPanel: React.FC = ({ )} - {/* 선택된 컬럼 순서 변경 */} + {/* 선택된 컬럼 순서 변경 (DnD) */} {config.columns && config.columns.length > 0 && (
-

컬럼 순서 / 설정

+

표시할 컬럼 ({config.columns.length}개 선택)

- 선택된 컬럼의 순서를 변경하거나 표시명을 수정할 수 있습니다 + 드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다


-
- {[...(config.columns || [])] - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map((column, idx) => ( - moveColumn(column.columnName, direction)} - onRemove={() => removeColumn(column.columnName)} - onUpdate={(updates) => updateColumn(column.columnName, updates)} - /> - ))} -
+ { + const { active, over } = event; + if (!over || active.id === over.id) return; + const columns = [...(config.columns || [])]; + const oldIndex = columns.findIndex((c) => c.columnName === active.id); + const newIndex = columns.findIndex((c) => c.columnName === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + const reordered = arrayMove(columns, oldIndex, newIndex); + reordered.forEach((col, idx) => { col.order = idx; }); + handleChange("columns", reordered); + } + }} + > + c.columnName)} + strategy={verticalListSortingStrategy} + > +
+ {(config.columns || []).map((column, idx) => { + // displayName이 columnName과 같으면 한국어 라벨 미설정 → availableColumns에서 찾기 + const resolvedLabel = + column.displayName && column.displayName !== column.columnName + ? column.displayName + : availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName; + + const colWithLabel = { ...column, displayName: resolvedLabel }; + return ( + updateColumn(column.columnName, { displayName: value })} + onWidthChange={(value) => updateColumn(column.columnName, { width: value })} + onRemove={() => removeColumn(column.columnName)} + /> + ); + })} +
+
+
)} @@ -1515,96 +1607,3 @@ export const TableListConfigPanel: React.FC = ({ ); }; -/** - * 선택된 컬럼 항목 컴포넌트 - * 순서 이동, 삭제, 표시명 수정 기능 제공 - */ -const SelectedColumnItem: React.FC<{ - column: ColumnConfig; - index: number; - total: number; - onMove: (direction: "up" | "down") => void; - onRemove: () => void; - onUpdate: (updates: Partial) => void; -}> = ({ column, index, total, onMove, onRemove, onUpdate }) => { - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(column.displayName || column.columnName); - - const handleSave = () => { - const trimmed = editValue.trim(); - if (trimmed && trimmed !== column.displayName) { - onUpdate({ displayName: trimmed }); - } - setIsEditing(false); - }; - - return ( -
- - - {index + 1} - - {isEditing ? ( - setEditValue(e.target.value)} - onBlur={handleSave} - onKeyDown={(e) => { - if (e.key === "Enter") handleSave(); - if (e.key === "Escape") { - setEditValue(column.displayName || column.columnName); - setIsEditing(false); - } - }} - className="h-5 flex-1 px-1 text-xs" - autoFocus - /> - ) : ( - - )} - -
- - - -
-
- ); -}; From a8ad26cf305b07bfed182107cd366b5cf7d3bce3 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 27 Feb 2026 15:24:55 +0900 Subject: [PATCH 24/25] refactor: Enhance horizontal label handling in dynamic components - Updated the InteractiveScreenViewerDynamic and RealtimePreviewDynamic components to improve horizontal label rendering and style management. - Refactored the DynamicComponentRenderer to support external horizontal labels, ensuring proper display and positioning based on component styles. - Cleaned up style handling by removing unnecessary border properties for horizontal labels, enhancing visual consistency. - Improved the logic for determining label display requirements, streamlining the rendering process for dynamic components. --- .../screen/InteractiveScreenViewerDynamic.tsx | 86 +++++++--- .../screen/RealtimePreviewDynamic.tsx | 15 +- .../lib/registry/DynamicComponentRenderer.tsx | 154 +++++++++++++++--- 3 files changed, 213 insertions(+), 42 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index bcf9959c..1bb04e97 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1119,6 +1119,12 @@ export const InteractiveScreenViewerDynamic: React.FC { const compType = (component as any).componentType || ""; const isSplitLine = type === "component" && compType === "v2-split-line"; @@ -1194,9 +1200,17 @@ export const InteractiveScreenViewerDynamic: React.FC { + const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize; + return rest; + })() + : safeStyleWithoutSize; + const componentStyle = { position: "absolute" as const, - ...safeStyleWithoutSize, + ...cleanedStyle, // left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게) left: adjustedX, top: position?.y || 0, @@ -1267,11 +1281,7 @@ export const InteractiveScreenViewerDynamic: React.FC
{needsExternalLabel ? ( -
- {externalLabelComponent} -
- {renderInteractiveWidget(componentToRender)} + isHorizLabel ? ( +
+ +
+ {renderInteractiveWidget(componentToRender)} +
-
+ ) : ( +
+ {externalLabelComponent} +
+ {renderInteractiveWidget(componentToRender)} +
+
+ ) ) : ( renderInteractiveWidget(componentToRender) )} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index b95506d9..dcca4d0d 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -548,10 +548,23 @@ const RealtimePreviewDynamicComponent: React.FC = ({ const origWidth = size?.width || 100; const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth; + // v2 수평 라벨 컴포넌트: position wrapper에서 border 제거 (DynamicComponentRenderer가 내부에서 처리) + const isV2HorizLabel = !!( + componentStyle && + (componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") && + (componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right") + ); + const safeComponentStyle = isV2HorizLabel + ? (() => { + const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any; + return rest; + })() + : componentStyle; + const baseStyle = { left: `${adjustedPositionX}px`, top: `${position.y}px`, - ...componentStyle, + ...safeComponentStyle, width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth, height: displayHeight, zIndex: component.type === "layout" ? 1 : position.z || 2, diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index ed98561c..50c4bee4 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -371,15 +371,18 @@ export const DynamicComponentRenderer: React.FC = try { const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer"); const fieldName = columnName || component.id; - const currentValue = props.formData?.[fieldName] || ""; - const handleChange = (value: any) => { - if (props.onFormDataChange) { - props.onFormDataChange(fieldName, value); - } - }; - - // V2SelectRenderer용 컴포넌트 데이터 구성 + // 수평 라벨 감지 + const catLabelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay; + const catLabelPosition = component.style?.labelPosition; + const catLabelText = (catLabelDisplay === true || catLabelDisplay === "true") + ? (component.style?.labelText || (component as any).label || component.componentConfig?.label) + : undefined; + const catNeedsExternalHorizLabel = !!( + catLabelText && + (catLabelPosition === "left" || catLabelPosition === "right") + ); + const selectComponent = { ...component, componentConfig: { @@ -395,6 +398,24 @@ export const DynamicComponentRenderer: React.FC = webType: "category", }; + const catStyle = catNeedsExternalHorizLabel + ? { + ...(component as any).style, + labelDisplay: false, + labelPosition: "top" as const, + width: "100%", + height: "100%", + borderWidth: undefined, + borderColor: undefined, + borderStyle: undefined, + border: undefined, + borderRadius: undefined, + } + : (component as any).style; + const catSize = catNeedsExternalHorizLabel + ? { ...(component as any).size, width: undefined, height: undefined } + : (component as any).size; + const rendererProps = { component: selectComponent, formData: props.formData, @@ -402,12 +423,47 @@ export const DynamicComponentRenderer: React.FC = isDesignMode: props.isDesignMode, isInteractive: props.isInteractive ?? !props.isDesignMode, tableName, - style: (component as any).style, - size: (component as any).size, + style: catStyle, + size: catSize, }; const rendererInstance = new V2SelectRenderer(rendererProps); - return rendererInstance.render(); + const renderedCatSelect = rendererInstance.render(); + + if (catNeedsExternalHorizLabel) { + const labelGap = component.style?.labelGap || "8px"; + const labelFontSize = component.style?.labelFontSize || "14px"; + const labelColor = component.style?.labelColor || "#64748b"; + const labelFontWeight = component.style?.labelFontWeight || "500"; + const isRequired = component.required || (component as any).required; + const isLeft = catLabelPosition === "left"; + return ( +
+ +
+ {renderedCatSelect} +
+
+ ); + } + return renderedCatSelect; } catch (error) { console.error("❌ V2SelectRenderer 로드 실패:", error); } @@ -625,12 +681,33 @@ export const DynamicComponentRenderer: React.FC = ? (component.style?.labelText || (component as any).label || component.componentConfig?.label) : undefined; + // 🔧 수평 라벨(left/right) 감지 → 외부 flex 컨테이너에서 라벨 처리 + const labelPosition = component.style?.labelPosition; + const isV2Component = componentType?.startsWith("v2-"); + const needsExternalHorizLabel = !!( + isV2Component && + effectiveLabel && + (labelPosition === "left" || labelPosition === "right") + ); + // 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀 const mergedStyle = { ...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저! // CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고) width: finalStyle.width, height: finalStyle.height, + // 수평 라벨 → V2 컴포넌트에는 라벨 비활성화 (외부에서 처리) + ...(needsExternalHorizLabel ? { + labelDisplay: false, + labelPosition: "top" as const, + width: "100%", + height: "100%", + borderWidth: undefined, + borderColor: undefined, + borderStyle: undefined, + border: undefined, + borderRadius: undefined, + } : {}), }; // 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선) @@ -649,7 +726,9 @@ export const DynamicComponentRenderer: React.FC = onClick, onDragStart, onDragEnd, - size: component.size || newComponent.defaultSize, + size: needsExternalHorizLabel + ? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined } + : (component.size || newComponent.defaultSize), position: component.position, config: mergedComponentConfig, componentConfig: mergedComponentConfig, @@ -657,8 +736,8 @@ export const DynamicComponentRenderer: React.FC = ...(mergedComponentConfig || {}), // 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선) style: mergedStyle, - // 🆕 라벨 표시 (labelDisplay가 true일 때만) - label: effectiveLabel, + // 수평 라벨 → 외부에서 처리하므로 label 전달 안 함 + label: needsExternalHorizLabel ? undefined : effectiveLabel, // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선) inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType, columnName: (component as any).columnName || component.componentConfig?.columnName, @@ -759,16 +838,51 @@ export const DynamicComponentRenderer: React.FC = NewComponentRenderer.prototype && NewComponentRenderer.prototype.render; + let renderedElement: React.ReactElement; if (isClass) { - // 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속) const rendererInstance = new NewComponentRenderer(rendererProps); - return rendererInstance.render(); + renderedElement = rendererInstance.render(); } else { - // 함수형 컴포넌트 - // refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제 - - return ; + renderedElement = ; } + + // 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움 + if (needsExternalHorizLabel) { + const labelGap = component.style?.labelGap || "8px"; + const labelFontSize = component.style?.labelFontSize || "14px"; + const labelColor = component.style?.labelColor || "#64748b"; + const labelFontWeight = component.style?.labelFontWeight || "500"; + const isRequired = component.required || (component as any).required; + const isLeft = labelPosition === "left"; + + return ( +
+ +
+ {renderedElement} +
+
+ ); + } + + return renderedElement; } } catch (error) { console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error); From e16d76936b13dd5a5c3789a37485f8a3f8091760 Mon Sep 17 00:00:00 2001 From: kjs Date: Sat, 28 Feb 2026 14:33:18 +0900 Subject: [PATCH 25/25] feat: Enhance V2Repeater and configuration panel with source detail auto-fetching - Added support for automatic fetching of detail rows from the master data in the V2Repeater component, improving data management. - Introduced a new configuration option in the V2RepeaterConfigPanel to enable source detail auto-fetching, allowing users to specify detail table and foreign key settings. - Enhanced the V2Repeater component to handle entity joins for loading data, optimizing data retrieval processes. - Updated the V2RepeaterProps and V2RepeaterConfig interfaces to include new properties for grouped data and source detail configuration, ensuring type safety and clarity in component usage. - Improved logging for data loading processes to provide better insights during development and debugging. --- frontend/components/v2/V2Repeater.tsx | 256 +++++++++--- .../config-panels/V2RepeaterConfigPanel.tsx | 128 ++++++ .../modal-repeater-table/RepeaterTable.tsx | 10 +- .../SplitPanelLayout2Component.tsx | 139 ++++++- .../TableSectionRenderer.tsx | 375 +++++++++++++----- .../UniversalFormModalComponent.tsx | 85 ++-- .../UniversalFormModalConfigPanel.tsx | 20 +- .../modals/TableSectionSettingsModal.tsx | 9 +- .../components/DetailFormModal.tsx | 9 +- .../v2-repeater/V2RepeaterRenderer.tsx | 3 + frontend/types/v2-repeater.ts | 24 +- 11 files changed, 858 insertions(+), 200 deletions(-) diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index b60617e6..f6f1fc6b 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -48,6 +48,7 @@ export const V2Repeater: React.FC = ({ onRowClick, className, formData: parentFormData, + groupedData, ...restProps }) => { // componentId 결정: 직접 전달 또는 component 객체에서 추출 @@ -419,65 +420,113 @@ export const V2Repeater: React.FC = ({ fkValue, }); - const response = await apiClient.post( - `/table-management/tables/${config.mainTableName}/data`, - { + let rows: any[] = []; + const useEntityJoinForLoad = config.sourceDetailConfig?.useEntityJoin; + + if (useEntityJoinForLoad) { + // 엔티티 조인을 사용하여 데이터 로드 (part_code → item_info 자동 조인) + const searchParam = JSON.stringify({ [config.foreignKeyColumn!]: fkValue }); + const params: Record = { page: 1, size: 1000, - dataFilter: { - enabled: true, - filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }], - }, - autoFilter: true, + search: searchParam, + enableEntityJoin: true, + autoFilter: JSON.stringify({ enabled: true }), + }; + const addJoinCols = config.sourceDetailConfig?.additionalJoinColumns; + if (addJoinCols && addJoinCols.length > 0) { + params.additionalJoinColumns = JSON.stringify(addJoinCols); } - ); + const response = await apiClient.get( + `/table-management/tables/${config.mainTableName}/data-with-joins`, + { params } + ); + const resultData = response.data?.data; + const rawRows = Array.isArray(resultData) + ? resultData + : resultData?.data || resultData?.rows || []; + // 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어날 수 있으므로 id 기준 중복 제거 + const seenIds = new Set(); + rows = rawRows.filter((row: any) => { + if (!row.id || seenIds.has(row.id)) return false; + seenIds.add(row.id); + return true; + }); + } else { + const response = await apiClient.post( + `/table-management/tables/${config.mainTableName}/data`, + { + page: 1, + size: 1000, + dataFilter: { + enabled: true, + filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }], + }, + autoFilter: true, + } + ); + rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || []; + } - const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || []; if (Array.isArray(rows) && rows.length > 0) { - console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`); + console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`, useEntityJoinForLoad ? "(엔티티 조인)" : ""); - // isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 - const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); - const sourceTable = config.dataSource?.sourceTable; - const fkColumn = config.dataSource?.foreignKey; - const refKey = config.dataSource?.referenceKey || "id"; + // 엔티티 조인 사용 시: columnMapping으로 _display_ 필드 보강 + const columnMapping = config.sourceDetailConfig?.columnMapping; + if (useEntityJoinForLoad && columnMapping) { + const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); + rows.forEach((row: any) => { + sourceDisplayColumns.forEach((col) => { + const mappedKey = columnMapping[col.key]; + const value = mappedKey ? row[mappedKey] : row[col.key]; + row[`_display_${col.key}`] = value ?? ""; + }); + }); + console.log("✅ [V2Repeater] 엔티티 조인 표시 데이터 보강 완료"); + } - if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) { - try { - const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean); - const uniqueValues = [...new Set(fkValues)]; + // isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 (엔티티 조인 미사용 시) + if (!useEntityJoinForLoad) { + const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); + const sourceTable = config.dataSource?.sourceTable; + const fkColumn = config.dataSource?.foreignKey; + const refKey = config.dataSource?.referenceKey || "id"; - if (uniqueValues.length > 0) { - // FK 값 기반으로 소스 테이블에서 해당 레코드만 조회 - const sourcePromises = uniqueValues.map((val) => - apiClient.post(`/table-management/tables/${sourceTable}/data`, { - page: 1, size: 1, - search: { [refKey]: val }, - autoFilter: true, - }).then((r) => r.data?.data?.data || r.data?.data?.rows || []) - .catch(() => []) - ); - const sourceResults = await Promise.all(sourcePromises); - const sourceMap = new Map(); - sourceResults.flat().forEach((sr: any) => { - if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr); - }); + if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) { + try { + const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean); + const uniqueValues = [...new Set(fkValues)]; - // 각 행에 소스 테이블의 표시 데이터 병합 - rows.forEach((row: any) => { - const sourceRecord = sourceMap.get(String(row[fkColumn])); - if (sourceRecord) { - sourceDisplayColumns.forEach((col) => { - const displayValue = sourceRecord[col.key] ?? null; - row[col.key] = displayValue; - row[`_display_${col.key}`] = displayValue; - }); - } - }); - console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료"); + if (uniqueValues.length > 0) { + const sourcePromises = uniqueValues.map((val) => + apiClient.post(`/table-management/tables/${sourceTable}/data`, { + page: 1, size: 1, + search: { [refKey]: val }, + autoFilter: true, + }).then((r) => r.data?.data?.data || r.data?.data?.rows || []) + .catch(() => []) + ); + const sourceResults = await Promise.all(sourcePromises); + const sourceMap = new Map(); + sourceResults.flat().forEach((sr: any) => { + if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr); + }); + + rows.forEach((row: any) => { + const sourceRecord = sourceMap.get(String(row[fkColumn])); + if (sourceRecord) { + sourceDisplayColumns.forEach((col) => { + const displayValue = sourceRecord[col.key] ?? null; + row[col.key] = displayValue; + row[`_display_${col.key}`] = displayValue; + }); + } + }); + console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료"); + } + } catch (sourceError) { + console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError); } - } catch (sourceError) { - console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError); } } @@ -964,8 +1013,113 @@ export const V2Repeater: React.FC = ({ [], ); - // V2Repeater는 자체 데이터 관리 (아이템 선택 모달, useCustomTable 로딩, DataReceiver)를 사용. - // EditModal의 groupedData는 메인 테이블 레코드이므로 V2Repeater에서는 사용하지 않음. + // sourceDetailConfig가 설정되고 groupedData(모달에서 전달된 마스터 데이터)가 있으면 + // 마스터의 키를 추출하여 디테일 테이블에서 행을 조회 → 리피터에 자동 세팅 + const sourceDetailLoadedRef = useRef(false); + useEffect(() => { + if (sourceDetailLoadedRef.current) return; + if (!groupedData || groupedData.length === 0) return; + if (!config.sourceDetailConfig) return; + + const { tableName, foreignKey, parentKey } = config.sourceDetailConfig; + if (!tableName || !foreignKey || !parentKey) return; + + const parentKeys = groupedData + .map((row) => row[parentKey]) + .filter((v) => v !== undefined && v !== null && v !== ""); + + if (parentKeys.length === 0) return; + + sourceDetailLoadedRef.current = true; + + const loadSourceDetails = async () => { + try { + const uniqueKeys = [...new Set(parentKeys)] as string[]; + const { useEntityJoin, columnMapping, additionalJoinColumns } = config.sourceDetailConfig!; + + let detailRows: any[] = []; + + if (useEntityJoin) { + // data-with-joins GET API 사용 (엔티티 조인 자동 적용) + const searchParam = JSON.stringify({ [foreignKey]: uniqueKeys.join("|") }); + const params: Record = { + page: 1, + size: 9999, + search: searchParam, + enableEntityJoin: true, + autoFilter: JSON.stringify({ enabled: true }), + }; + if (additionalJoinColumns && additionalJoinColumns.length > 0) { + params.additionalJoinColumns = JSON.stringify(additionalJoinColumns); + } + const resp = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params }); + const resultData = resp.data?.data; + const rawRows = Array.isArray(resultData) + ? resultData + : resultData?.data || resultData?.rows || []; + // 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어나므로 id 기준 중복 제거 + const seenIds = new Set(); + detailRows = rawRows.filter((row: any) => { + if (!row.id || seenIds.has(row.id)) return false; + seenIds.add(row.id); + return true; + }); + } else { + // 기존 POST API 사용 + const resp = await apiClient.post(`/table-management/tables/${tableName}/data`, { + page: 1, + size: 9999, + search: { [foreignKey]: uniqueKeys }, + }); + const resultData = resp.data?.data; + detailRows = Array.isArray(resultData) + ? resultData + : resultData?.data || resultData?.rows || []; + } + + if (detailRows.length === 0) { + console.warn("[V2Repeater] sourceDetail 조회 결과 없음:", { tableName, uniqueKeys }); + return; + } + + console.log("[V2Repeater] sourceDetail 조회 완료:", detailRows.length, "건", useEntityJoin ? "(엔티티 조인)" : ""); + + // 디테일 행을 리피터 컬럼에 매핑 + const newRows = detailRows.map((detail, index) => { + const row: any = { _id: `src_detail_${Date.now()}_${index}` }; + for (const col of config.columns) { + if (col.isSourceDisplay) { + // columnMapping이 있으면 조인 alias에서 값 가져오기 (표시용) + const mappedKey = columnMapping?.[col.key]; + const value = mappedKey ? detail[mappedKey] : detail[col.key]; + row[`_display_${col.key}`] = value ?? ""; + // 원본 값도 저장 (DB persist용 - _display_ 접두사 없이) + if (detail[col.key] !== undefined) { + row[col.key] = detail[col.key]; + } + } else if (col.autoFill) { + const autoValue = generateAutoFillValueSync(col, index, parentFormData); + row[col.key] = autoValue ?? ""; + } else if (col.sourceKey && detail[col.sourceKey] !== undefined) { + row[col.key] = detail[col.sourceKey]; + } else if (detail[col.key] !== undefined) { + row[col.key] = detail[col.key]; + } else { + row[col.key] = ""; + } + } + return row; + }); + + setData(newRows); + onDataChange?.(newRows); + } catch (error) { + console.error("[V2Repeater] sourceDetail 조회 실패:", error); + } + }; + + loadSourceDetails(); + }, [groupedData, config.sourceDetailConfig, config.columns, generateAutoFillValueSync, parentFormData, onDataChange]); // parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신 useEffect(() => { diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 1f89ae12..66f0f18b 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -31,6 +31,7 @@ import { Wand2, Check, ChevronsUpDown, + ListTree, } from "lucide-react"; import { Command, @@ -983,6 +984,133 @@ export const V2RepeaterConfigPanel: React.FC = ({ + {/* 소스 디테일 자동 조회 설정 */} +
+
+ { + if (checked) { + updateConfig({ + sourceDetailConfig: { + tableName: "", + foreignKey: "", + parentKey: "", + }, + }); + } else { + updateConfig({ sourceDetailConfig: undefined }); + } + }} + /> + +
+

+ 모달에서 전달받은 마스터 데이터의 디테일 행을 자동으로 조회하여 리피터에 채웁니다. +

+ + {config.sourceDetailConfig && ( +
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {allTables.map((table) => ( + { + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + tableName: table.tableName, + }, + }); + }} + className="text-xs" + > + + {table.displayName} + + ))} + + + + + +
+ +
+
+ + + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + foreignKey: e.target.value, + }, + }) + } + placeholder="예: order_no" + className="h-7 text-xs" + /> +
+
+ + + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + parentKey: e.target.value, + }, + }) + } + placeholder="예: order_no" + className="h-7 text-xs" + /> +
+
+ +

+ 마스터에서 [{config.sourceDetailConfig.parentKey || "?"}] 추출 → + {" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"} 로 조회 +

+
+ )} +
+ + + {/* 기능 옵션 */}
diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index d57ae60b..532881b7 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -553,14 +553,20 @@ export function RepeaterTable({ const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const value = row[column.field]; - // 카테고리 라벨 변환 함수 + // 카테고리/셀렉트 라벨 변환 함수 const getCategoryDisplayValue = (val: any): string => { if (!val || typeof val !== "string") return val || "-"; + // select 타입 컬럼의 selectOptions에서 라벨 찾기 + if (column.selectOptions && column.selectOptions.length > 0) { + const matchedOption = column.selectOptions.find((opt) => opt.value === val); + if (matchedOption) return matchedOption.label; + } + const fieldName = column.field.replace(/^_display_/, ""); const isCategoryColumn = categoryColumns.includes(fieldName); - // categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관) + // categoryLabelMap에 직접 매핑이 있으면 바로 변환 if (categoryLabelMap[val]) return categoryLabelMap[val]; // 카테고리 컬럼이 아니면 원래 값 반환 diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index a06c046f..6c631d83 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { apiClient } from "@/lib/api/client"; +import { getCategoryValues } from "@/lib/api/tableCategoryValue"; export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { // 추가 props @@ -92,6 +93,9 @@ export const SplitPanelLayout2Component: React.FC(null); const [rightActiveTab, setRightActiveTab] = useState(null); + // 카테고리 코드→라벨 매핑 + const [categoryLabelMap, setCategoryLabelMap] = useState>({}); + // 프론트엔드 그룹핑 함수 const groupData = useCallback( (data: Record[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record[] => { @@ -185,17 +189,17 @@ export const SplitPanelLayout2Component: React.FC ({ id: value, - label: value, + label: categoryLabelMap[value] || value, count: tabConfig.showCount ? count : 0, })); console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`); return tabs; }, - [], + [categoryLabelMap], ); // 탭으로 필터링된 데이터 반환 @@ -1000,10 +1004,38 @@ export const SplitPanelLayout2Component: React.FC { + loadLeftData(); + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(editEvent); + console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", selectedLeftItem); break; + } case "delete": // 좌측 패널에서 삭제 (필요시 구현) @@ -1018,7 +1050,7 @@ export const SplitPanelLayout2Component: React.FC { + if (isDesignMode) return; + + const loadCategoryLabels = async () => { + const allColumns = new Set(); + const tableName = config.leftPanel?.tableName || config.rightPanel?.tableName; + if (!tableName) return; + + // 좌우 패널의 표시 컬럼에서 카테고리 후보 수집 + for (const col of config.leftPanel?.displayColumns || []) { + allColumns.add(col.name); + } + for (const col of config.rightPanel?.displayColumns || []) { + allColumns.add(col.name); + } + // 탭 소스 컬럼도 추가 + if (config.rightPanel?.tabConfig?.tabSourceColumn) { + allColumns.add(config.rightPanel.tabConfig.tabSourceColumn); + } + if (config.leftPanel?.tabConfig?.tabSourceColumn) { + allColumns.add(config.leftPanel.tabConfig.tabSourceColumn); + } + + const labelMap: Record = {}; + + for (const columnName of allColumns) { + try { + const result = await getCategoryValues(tableName, columnName); + if (result.success && Array.isArray(result.data) && result.data.length > 0) { + for (const item of result.data) { + if (item.valueCode && item.valueLabel) { + labelMap[item.valueCode] = item.valueLabel; + } + } + } + } catch { + // 카테고리가 아닌 컬럼은 무시 + } + } + + if (Object.keys(labelMap).length > 0) { + setCategoryLabelMap(labelMap); + } + }; + + loadCategoryLabels(); + }, [isDesignMode, config.leftPanel?.tableName, config.rightPanel?.tableName, config.leftPanel?.displayColumns, config.rightPanel?.displayColumns, config.rightPanel?.tabConfig?.tabSourceColumn, config.leftPanel?.tabConfig?.tabSourceColumn]); + // 컴포넌트 언마운트 시 DataProvider 해제 useEffect(() => { return () => { @@ -1250,6 +1331,23 @@ export const SplitPanelLayout2Component: React.FC { + if (value === null || value === undefined) return ""; + const strVal = String(value); + if (categoryLabelMap[strVal]) return categoryLabelMap[strVal]; + // 콤마 구분 다중 값 처리 + if (strVal.includes(",")) { + const codes = strVal.split(",").map((c) => c.trim()).filter(Boolean); + const labels = codes.map((code) => categoryLabelMap[code] || code); + return labels.join(", "); + } + return strVal; + }, + [categoryLabelMap], + ); + // 컬럼 값 가져오기 (sourceTable 및 엔티티 참조 고려) const getColumnValue = useCallback( (item: any, col: ColumnConfig): any => { @@ -1547,7 +1645,7 @@ export const SplitPanelLayout2Component: React.FC { const value = item[col.name]; if (value === null || value === undefined) return "-"; @@ -1558,7 +1656,7 @@ export const SplitPanelLayout2Component: React.FC {value.map((v, vIdx) => ( - {formatValue(v, col.format)} + {resolveCategoryLabel(v) || formatValue(v, col.format)} ))}
@@ -1567,14 +1665,17 @@ export const SplitPanelLayout2Component: React.FC - {formatValue(value, col.format)} + {label !== String(value) ? label : formatValue(value, col.format)} ); } - // 기본 텍스트 + // 카테고리 라벨 변환 시도 후 기본 텍스트 + const label = resolveCategoryLabel(value); + if (label !== String(value)) return label; return formatValue(value, col.format); }; @@ -1821,9 +1922,12 @@ export const SplitPanelLayout2Component: React.FC )} - {displayColumns.map((col, colIdx) => ( - {formatValue(getColumnValue(item, col), col.format)} - ))} + {displayColumns.map((col, colIdx) => { + const rawVal = getColumnValue(item, col); + const resolved = resolveCategoryLabel(rawVal); + const display = resolved !== String(rawVal ?? "") ? resolved : formatValue(rawVal, col.format); + return {display || "-"}; + })} {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
@@ -2133,7 +2237,12 @@ export const SplitPanelLayout2Component: React.FC 0 && (
- {config.leftPanel.actionButtons.map((btn, idx) => ( + {config.leftPanel.actionButtons + .filter((btn) => { + if (btn.showCondition === "selected") return !!selectedLeftItem; + return true; + }) + .map((btn, idx) => (