diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 173de022..c1748123 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1502,6 +1502,26 @@ export class TableManagementService { columnName ); + // πŸ†• λ°°μ—΄ 처리: IN 절 μ‚¬μš© + if (Array.isArray(value)) { + if (value.length === 0) { + // 빈 배열이면 항상 false 쑰건 + return { + whereClause: `1 = 0`, + values: [], + paramCount: 0, + }; + } + + // IN 절둜 μ—¬λŸ¬ κ°’ 검색 + const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + return { + whereClause: `${columnName} IN (${placeholders})`, + values: value, + paramCount: value.length, + }; + } + if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) { // μ—”ν‹°ν‹° νƒ€μž…μ΄ μ•„λ‹ˆλ©΄ κΈ°λ³Έ 검색 return { diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 08199609..95679adc 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -4245,8 +4245,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD {/* 톡합 νŒ¨λ„ */} {panelStates.unified?.isOpen && ( -
-
+
+

νŒ¨λ„

-
+
diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index e3940073..8bd98304 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -238,9 +238,9 @@ export const UnifiedPropertiesPanel: React.FC = ({ // μ»΄ν¬λ„ŒνŠΈκ°€ μ„ νƒλ˜μ§€ μ•Šμ•˜μ„ λ•Œλ„ 해상도 μ„€μ •κ³Ό 격자 섀정은 ν‘œμ‹œ if (!selectedComponent) { return ( -
+
{/* 해상도 μ„€μ •κ³Ό 격자 μ„€μ • ν‘œμ‹œ */} -
+
{/* 해상도 μ„€μ • */} {currentResolution && onResolutionChange && ( @@ -1403,7 +1403,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ }; return ( -
+
{/* 헀더 - κ°„μ†Œν™” */}
{selectedComponent.type === "widget" && ( @@ -1414,7 +1414,7 @@ export const UnifiedPropertiesPanel: React.FC = ({
{/* 톡합 컨텐츠 (νƒ­ 제거) */} -
+
{/* 해상도 μ„€μ • - 항상 맨 μœ„μ— ν‘œμ‹œ */} {currentResolution && onResolutionChange && ( diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx index aef7dd9d..81e90fd3 100644 --- a/frontend/components/ui/select.tsx +++ b/frontend/components/ui/select.tsx @@ -31,7 +31,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-48 items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} diff --git a/frontend/lib/registry/components/repeat-screen-modal/README.md b/frontend/lib/registry/components/repeat-screen-modal/README.md index 6ba2783a..cb22964d 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/README.md +++ b/frontend/lib/registry/components/repeat-screen-modal/README.md @@ -1,10 +1,63 @@ -# RepeatScreenModal μ»΄ν¬λ„ŒνŠΈ v3 +# RepeatScreenModal μ»΄ν¬λ„ŒνŠΈ v3.1 ## κ°œμš” `RepeatScreenModal`은 μ„ νƒν•œ 데이터λ₯Ό 기반으둜 μ—¬λŸ¬ 개의 μΉ΄λ“œλ₯Ό μƒμ„±ν•˜κ³ , 각 μΉ΄λ“œμ˜ λ‚΄λΆ€ λ ˆμ΄μ•„μ›ƒμ„ 자유둭게 ꡬ성할 수 μžˆλŠ” μ»΄ν¬λ„ŒνŠΈμž…λ‹ˆλ‹€. -## v3 μ£Όμš” 변경사항 +## v3.1 μ£Όμš” 변경사항 (2025-11-28) + +### 1. μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ†ŒμŠ€ + +ν…Œμ΄λΈ” ν–‰μ—μ„œ **μ™ΈλΆ€ ν…Œμ΄λΈ”μ˜ 데이터λ₯Ό 쑰회**ν•˜μ—¬ ν‘œμ‹œν•  수 μžˆμŠ΅λ‹ˆλ‹€. + +``` +μ˜ˆμ‹œ: 수주 κ΄€λ¦¬μ—μ„œ μΆœν•˜ κ³„νš 이λ ₯ 쑰회 +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ μΉ΄λ“œ: ν’ˆλͺ© A β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ [ν–‰ 1] 헀더: ν’ˆλͺ©μ½”λ“œ, ν’ˆλͺ©λͺ… β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ [ν–‰ 2] ν…Œμ΄λΈ”: shipment_plan ν…Œμ΄λΈ”μ—μ„œ 쑰회 β”‚ +β”‚ β†’ sales_order_id둜 μ‘°μΈν•˜μ—¬ μΆœν•˜ κ³„νš 이λ ₯ ν‘œμ‹œ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### 2. ν…Œμ΄λΈ” ν–‰ CRUD + +ν…Œμ΄λΈ” ν–‰μ—μ„œ **ν–‰ μΆ”κ°€/μˆ˜μ •/μ‚­μ œ** κΈ°λŠ₯을 μ§€μ›ν•©λ‹ˆλ‹€. + +- **μΆ”κ°€**: μƒˆ ν–‰ μΆ”κ°€ λ²„νŠΌμœΌλ‘œ 빈 ν–‰ 생성 +- **μˆ˜μ •**: νŽΈμ§‘ κ°€λŠ₯ν•œ 컬럼 직접 μˆ˜μ • +- **μ‚­μ œ**: ν–‰ μ‚­μ œ (확인 νŒμ—… μ˜΅μ…˜) + +### 3. Footer λ²„νŠΌ μ˜μ—­ + +λͺ¨λ‹¬ ν•˜λ‹¨μ— **μ»€μŠ€ν„°λ§ˆμ΄μ§• κ°€λŠ₯ν•œ λ²„νŠΌ μ˜μ—­**을 μ œκ³΅ν•©λ‹ˆλ‹€. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ μΉ΄λ“œ λ‚΄μš©... β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ [μ΄ˆκΈ°ν™”] [μ·¨μ†Œ] [μ €μž₯] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### 4. 집계 연산식 지원 + +집계 ν–‰μ—μ„œ **컬럼 κ°„ 사칙연산**을 μ§€μ›ν•©λ‹ˆλ‹€. + +```typescript +// 예: λ―ΈμΆœν•˜ μˆ˜λŸ‰ = μˆ˜μ£Όμˆ˜λŸ‰ - μΆœν•˜μˆ˜λŸ‰ +{ + sourceType: "formula", + formula: "{order_qty} - {ship_qty}", + label: "λ―ΈμΆœν•˜ μˆ˜λŸ‰" +} +``` + +--- + +## v3 μ£Όμš” 변경사항 (κΈ°μ‘΄) ### 자유 λ ˆμ΄μ•„μ›ƒ μ‹œμŠ€ν…œ @@ -33,29 +86,7 @@ | **집계 (aggregation)** | κ·Έλ£Ή λ‚΄ 데이터 집계값 ν‘œμ‹œ | μ΄μˆ˜λŸ‰, ν•©κ³„κΈˆμ•‘ λ“± | | **ν…Œμ΄λΈ” (table)** | κ·Έλ£Ή λ‚΄ 각 행을 ν…Œμ΄λΈ”λ‘œ ν‘œμ‹œ | 수주λͺ©λ‘, ν’ˆλͺ©λͺ©λ‘ λ“± | -### 자유둜운 μ‘°ν•© - -``` -μ˜ˆμ‹œ 1: 헀더 + 집계 + ν…Œμ΄λΈ” (μΆœν•˜κ³„νš) -β”œβ”€β”€ [ν–‰ 1] 헀더: ν’ˆλͺ©μ½”λ“œ, ν’ˆλͺ©λͺ… -β”œβ”€β”€ [ν–‰ 2] 집계: μ΄μˆ˜μ£Όμž”λŸ‰, ν˜„μž¬κ³  -└── [ν–‰ 3] ν…Œμ΄λΈ”: μˆ˜μ£Όλ³„ μΆœν•˜κ³„νš - -μ˜ˆμ‹œ 2: μ§‘κ³„λ§Œ -└── [ν–‰ 1] 집계: 총맀좜, μ΄λΉ„μš©, 순이읡 - -μ˜ˆμ‹œ 3: ν…Œμ΄λΈ”λ§Œ -└── [ν–‰ 1] ν…Œμ΄λΈ”: ν’ˆλͺ© λͺ©λ‘ - -μ˜ˆμ‹œ 4: ν…Œμ΄λΈ” 2개 -β”œβ”€β”€ [ν–‰ 1] ν…Œμ΄λΈ”: μž…κ³  λ‚΄μ—­ -└── [ν–‰ 2] ν…Œμ΄λΈ”: 좜고 λ‚΄μ—­ - -μ˜ˆμ‹œ 5: 헀더 + 헀더 + ν•„λ“œ -β”œβ”€β”€ [ν–‰ 1] 헀더: κΈ°λ³Έ 정보 (μ½κΈ°μ „μš©) -β”œβ”€β”€ [ν–‰ 2] 헀더: 상세 정보 (μ½κΈ°μ „μš©) -└── [ν–‰ 3] ν•„λ“œ: μž…λ ₯ ν•„λ“œ (νŽΈμ§‘κ°€λŠ₯) -``` +--- ## μ„€μ • 방법 @@ -107,13 +138,34 @@ - **집계 ν•„λ“œ**: κ·Έλ£Ή νƒ­μ—μ„œ μ •μ˜ν•œ 집계 κ²°κ³Ό 선택 - **μŠ€νƒ€μΌ**: 배경색, 폰트 크기 -#### ν…Œμ΄λΈ” ν–‰ μ„€μ • +#### ν…Œμ΄λΈ” ν–‰ μ„€μ • (v3.1 ν™•μž₯) - **ν…Œμ΄λΈ” 제λͺ©**: 선택사항 - **헀더 ν‘œμ‹œ**: ν…Œμ΄λΈ” 헀더 ν‘œμ‹œ μ—¬λΆ€ +- **μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ†ŒμŠ€**: (v3.1 μ‹ κ·œ) + - μ†ŒμŠ€ ν…Œμ΄λΈ”: μ‘°νšŒν•  μ™ΈλΆ€ ν…Œμ΄λΈ” + - 쑰인 쑰건: μ™ΈλΆ€ ν…Œμ΄λΈ” ν‚€ ↔ μΉ΄λ“œ 데이터 ν‚€ + - μ •λ ¬: μ •λ ¬ 컬럼 및 λ°©ν–₯ +- **CRUD μ„€μ •**: (v3.1 μ‹ κ·œ) + - μΆ”κ°€: μƒˆ ν–‰ μΆ”κ°€ ν—ˆμš© + - μˆ˜μ •: ν–‰ μˆ˜μ • ν—ˆμš© + - μ‚­μ œ: ν–‰ μ‚­μ œ ν—ˆμš© (확인 νŒμ—… μ˜΅μ…˜) - **ν…Œμ΄λΈ” 컬럼**: ν•„λ“œλͺ…, 라벨, νƒ€μž…, λ„ˆλΉ„, νŽΈμ§‘ κ°€λŠ₯ - **μ €μž₯ μ„€μ •**: νŽΈμ§‘ κ°€λŠ₯ν•œ 컬럼의 μ €μž₯ μœ„μΉ˜ +### 5. Footer νƒ­ (v3.1 μ‹ κ·œ) + +- **Footer μ‚¬μš©**: Footer μ˜μ—­ ν™œμ„±ν™” +- **μœ„μΉ˜**: 컨텐츠 μ•„λž˜ / ν•˜λ‹¨ κ³ μ • (sticky) +- **μ •λ ¬**: μ™Όμͺ½ / κ°€μš΄λ° / 였λ₯Έμͺ½ +- **λ²„νŠΌ μ„€μ •**: + - 라벨: λ²„νŠΌ ν…μŠ€νŠΈ + - μ•‘μ…˜: μ €μž₯ / μ·¨μ†Œ / λ‹«κΈ° / μ΄ˆκΈ°ν™” / μ»€μŠ€ν…€ + - μŠ€νƒ€μΌ: κΈ°λ³Έ / 보쑰 / μ™Έκ³½μ„  / μ‚­μ œ / 고슀트 + - μ•„μ΄μ½˜: μ €μž₯ / X / μ΄ˆκΈ°ν™” / μ—†μŒ + +--- + ## 데이터 흐름 ``` @@ -125,16 +177,22 @@ ↓ 4. 각 그룹에 λŒ€ν•΄ 집계값 계산 ↓ -5. μΉ΄λ“œ λ Œλ”λ§ (contentRows 기반) +5. μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ†ŒμŠ€κ°€ μ„€μ •λœ ν…Œμ΄λΈ” ν–‰μ˜ 데이터 λ‘œλ“œ (v3.1) ↓ -6. μ‚¬μš©μž νŽΈμ§‘ +6. μΉ΄λ“œ λ Œλ”λ§ (contentRows 기반) ↓ -7. μ €μž₯ μ‹œ targetConfig에 따라 ν…Œμ΄λΈ”λ³„λ‘œ 데이터 λΆ„λ₯˜ ν›„ μ €μž₯ +7. μ‚¬μš©μž νŽΈμ§‘ (CRUD 포함) + ↓ +8. Footer λ²„νŠΌ λ˜λŠ” κΈ°λ³Έ μ €μž₯ λ²„νŠΌμœΌλ‘œ μ €μž₯ + ↓ +9. κΈ°λ³Έ 데이터 + μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 일괄 μ €μž₯ ``` +--- + ## μ‚¬μš© μ˜ˆμ‹œ -### μΆœν•˜κ³„νš 등둝 +### μΆœν•˜κ³„νš 등둝 (v3.1 - μ™ΈλΆ€ ν…Œμ΄λΈ” + CRUD) ```typescript { @@ -167,40 +225,185 @@ type: "aggregation", aggregationLayout: "horizontal", aggregationFields: [ - { aggregationResultField: "total_balance", label: "μ΄μˆ˜μ£Όμž”λŸ‰", backgroundColor: "blue" }, - { aggregationResultField: "order_count", label: "수주건수", backgroundColor: "green" } + { sourceType: "aggregation", aggregationResultField: "total_balance", label: "μ΄μˆ˜μ£Όμž”λŸ‰", backgroundColor: "blue" }, + { sourceType: "formula", formula: "{order_qty} - {ship_qty}", label: "λ―ΈμΆœν•˜ μˆ˜λŸ‰", backgroundColor: "orange" } ] }, { id: "row-3", type: "table", - tableTitle: "수주 λͺ©λ‘", + tableTitle: "μΆœν•˜ κ³„νš 이λ ₯", showTableHeader: true, + // μ™ΈλΆ€ ν…Œμ΄λΈ”μ—μ„œ 데이터 쑰회 + tableDataSource: { + enabled: true, + sourceTable: "shipment_plan", + joinConditions: [ + { sourceKey: "sales_order_id", referenceKey: "id" } + ], + orderBy: { column: "created_date", direction: "desc" } + }, + // CRUD μ„€μ • + tableCrud: { + allowCreate: true, + allowUpdate: true, + allowDelete: true, + newRowDefaults: { + sales_order_id: "{id}", + status: "READY" + }, + deleteConfirm: { enabled: true } + }, tableColumns: [ - { id: "tc1", field: "order_no", label: "수주번호", type: "text", editable: false }, - { id: "tc2", field: "partner_name", label: "거래처", type: "text", editable: false }, - { id: "tc3", field: "balance_qty", label: "λ―ΈμΆœν•˜", type: "number", editable: false }, - { - id: "tc4", - field: "plan_qty", - label: "μΆœν•˜κ³„νš", - type: "number", - editable: true, - targetConfig: { targetTable: "shipment_plan", targetColumn: "plan_qty", saveEnabled: true } - } + { id: "tc1", field: "plan_date", label: "κ³„νšμΌ", type: "date", editable: true }, + { id: "tc2", field: "plan_qty", label: "κ³„νšμˆ˜λŸ‰", type: "number", editable: true }, + { id: "tc3", field: "status", label: "μƒνƒœ", type: "text", editable: false }, + { id: "tc4", field: "memo", label: "λΉ„κ³ ", type: "text", editable: true } ] } - ] + ], + // Footer μ„€μ • + footerConfig: { + enabled: true, + position: "sticky", + alignment: "right", + buttons: [ + { id: "btn-cancel", label: "μ·¨μ†Œ", action: "cancel", variant: "outline" }, + { id: "btn-save", label: "μ €μž₯", action: "save", variant: "default", icon: "save" } + ] + } } ``` +--- + +## νƒ€μž… μ •μ˜ (v3.1) + +### TableDataSourceConfig + +```typescript +interface TableDataSourceConfig { + enabled: boolean; // μ™ΈλΆ€ 데이터 μ†ŒμŠ€ μ‚¬μš© μ—¬λΆ€ + sourceTable: string; // μ‘°νšŒν•  ν…Œμ΄λΈ” + joinConditions: JoinCondition[]; // 쑰인 쑰건 + orderBy?: { + column: string; // μ •λ ¬ 컬럼 + direction: "asc" | "desc"; // μ •λ ¬ λ°©ν–₯ + }; + limit?: number; // μ΅œλŒ€ ν–‰ 수 +} + +interface JoinCondition { + sourceKey: string; // μ™ΈλΆ€ ν…Œμ΄λΈ”μ˜ 쑰인 ν‚€ + referenceKey: string; // μΉ΄λ“œ λ°μ΄ν„°μ˜ μ°Έμ‘° ν‚€ + referenceType?: "card" | "row"; // μ°Έμ‘° μ†ŒμŠ€ +} +``` + +### TableCrudConfig + +```typescript +interface TableCrudConfig { + allowCreate: boolean; // ν–‰ μΆ”κ°€ ν—ˆμš© + allowUpdate: boolean; // ν–‰ μˆ˜μ • ν—ˆμš© + allowDelete: boolean; // ν–‰ μ‚­μ œ ν—ˆμš© + newRowDefaults?: Record; // μ‹ κ·œ ν–‰ κΈ°λ³Έκ°’ ({field} ν˜•μ‹ 지원) + deleteConfirm?: { + enabled: boolean; // μ‚­μ œ 확인 νŒμ—… + message?: string; // 확인 λ©”μ‹œμ§€ + }; + targetTable?: string; // μ €μž₯ λŒ€μƒ ν…Œμ΄λΈ” +} +``` + +### FooterConfig + +```typescript +interface FooterConfig { + enabled: boolean; // Footer μ‚¬μš© μ—¬λΆ€ + buttons?: FooterButtonConfig[]; + position?: "sticky" | "static"; + alignment?: "left" | "center" | "right"; +} + +interface FooterButtonConfig { + id: string; + label: string; + action: "save" | "cancel" | "close" | "reset" | "custom"; + variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; + icon?: string; + disabled?: boolean; + customAction?: { + type: string; + config?: Record; + }; +} +``` + +### AggregationDisplayConfig (v3.1 ν™•μž₯) + +```typescript +interface AggregationDisplayConfig { + // κ°’ μ†ŒμŠ€ νƒ€μž… + sourceType: "aggregation" | "formula" | "external" | "externalFormula"; + + // aggregation: κΈ°μ‘΄ 집계 κ²°κ³Ό μ°Έμ‘° + aggregationResultField?: string; + + // formula: 컬럼 κ°„ μ—°μ‚° + formula?: string; // 예: "{order_qty} - {ship_qty}" + + // external: μ™ΈλΆ€ ν…Œμ΄λΈ” 쑰회 (ν–₯ν›„ κ΅¬ν˜„) + externalSource?: ExternalValueSource; + + // externalFormula: μ™ΈλΆ€ ν…Œμ΄λΈ” + μ—°μ‚° (ν–₯ν›„ κ΅¬ν˜„) + externalSources?: ExternalValueSource[]; + externalFormula?: string; + + // ν‘œμ‹œ μ„€μ • + label: string; + icon?: string; + backgroundColor?: string; + textColor?: string; + fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; + format?: "number" | "currency" | "percent"; + decimalPlaces?: number; +} +``` + +--- + ## λ ˆκ±°μ‹œ ν˜Έν™˜ v2μ—μ„œ μ‚¬μš©ν•˜λ˜ `cardMode`, `cardLayout`, `tableLayout` 섀정도 계속 μ§€μ›λ©λ‹ˆλ‹€. μƒˆλ‘œμš΄ ν”„λ‘œμ νŠΈμ—μ„œλŠ” `contentRows`λ₯Ό μ‚¬μš©ν•˜λŠ” 것을 ꢌμž₯ν•©λ‹ˆλ‹€. +--- + ## μ£Όμ˜μ‚¬ν•­ 1. **μ§‘κ³„λŠ” κ·Έλ£Ήν•‘ ν•„μˆ˜**: 집계 행은 그룹핑이 ν™œμ„±ν™”λ˜μ–΄ μžˆμ–΄μ•Ό μ˜λ―Έκ°€ μžˆμŠ΅λ‹ˆλ‹€. 2. **ν…Œμ΄λΈ”μ€ κ·Έλ£Ήν•‘ ν•„μˆ˜**: ν…Œμ΄λΈ” 행도 그룹핑이 ν™œμ„±ν™”λ˜μ–΄ μžˆμ–΄μ•Ό κ·Έλ£Ή λ‚΄ 행듀을 ν‘œμ‹œν•  수 μžˆμŠ΅λ‹ˆλ‹€. 3. **λ‹¨μˆœ λͺ¨λ“œ**: κ·Έλ£Ήν•‘ 없이 μ‚¬μš©ν•˜λ©΄ 1ν–‰ = 1μΉ΄λ“œλ‘œ λ™μž‘ν•©λ‹ˆλ‹€. 이 경우 헀더/ν•„λ“œ νƒ€μž…λ§Œ μ‚¬μš© κ°€λŠ₯ν•©λ‹ˆλ‹€. +4. **μ™ΈλΆ€ ν…Œμ΄λΈ” CRUD**: μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ†ŒμŠ€κ°€ μ„€μ •λœ ν…Œμ΄λΈ”μ—μ„œλ§Œ CRUDκ°€ λ™μž‘ν•©λ‹ˆλ‹€. +5. **연산식**: 사칙연산(+, -, *, /)κ³Ό κ΄„ν˜Έλ§Œ μ§€μ›λ©λ‹ˆλ‹€. λ³΅μž‘ν•œ ν•¨μˆ˜λŠ” μ§€μ›ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. + +--- + +## λ³€κ²½ 이λ ₯ + +### v3.1 (2025-11-28) +- μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ†ŒμŠ€ κΈ°λŠ₯ μΆ”κ°€ +- ν…Œμ΄λΈ” ν–‰ CRUD (μΆ”κ°€/μˆ˜μ •/μ‚­μ œ) κΈ°λŠ₯ μΆ”κ°€ +- Footer λ²„νŠΌ μ˜μ—­ κΈ°λŠ₯ μΆ”κ°€ +- 집계 연산식 (formula) 지원 μΆ”κ°€ +- 닀단계 쑰인 νƒ€μž… μ •μ˜ μΆ”κ°€ (ν–₯ν›„ κ΅¬ν˜„ μ˜ˆμ •) + +### v3.0 +- 자유 λ ˆμ΄μ•„μ›ƒ μ‹œμŠ€ν…œ λ„μž… +- contentRows 기반 ν–‰ νƒ€μž… 선택 방식 +- 헀더/ν•„λ“œ/집계/ν…Œμ΄λΈ” 4κ°€μ§€ ν–‰ νƒ€μž… 지원 + +### v2.0 +- simple λͺ¨λ“œ / withTable λͺ¨λ“œ ꡬ뢄 +- cardLayout / tableLayout 뢄리 diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 997b381c..25807607 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -9,7 +9,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Loader2, Save, X, Layers, Table as TableIcon } from "lucide-react"; +import { Loader2, Save, X, Layers, Table as TableIcon, Plus, Trash2, RotateCcw } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { RepeatScreenModalProps, CardData, @@ -20,6 +30,10 @@ import { TableColumnConfig, CardContentRowConfig, AggregationDisplayConfig, + FooterConfig, + FooterButtonConfig, + TableDataSourceConfig, + TableCrudConfig, } from "./types"; import { ComponentRendererProps } from "@/types/component"; import { cn } from "@/lib/utils"; @@ -59,6 +73,9 @@ export function RepeatScreenModalComponent({ // πŸ†• v3: 자유 λ ˆμ΄μ•„μ›ƒ const contentRows = componentConfig?.contentRows || []; + // πŸ†• v3.1: Footer μ„€μ • + const footerConfig = componentConfig?.footerConfig; + // (λ ˆκ±°μ‹œ ν˜Έν™˜) const cardLayout = componentConfig?.cardLayout || []; const cardMode = componentConfig?.cardMode || "simple"; @@ -71,6 +88,16 @@ export function RepeatScreenModalComponent({ const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [isSaving, setIsSaving] = useState(false); + + // πŸ†• v3.1: μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 (ν…Œμ΄λΈ” ν–‰λ³„λ‘œ 관리) + const [externalTableData, setExternalTableData] = useState>({}); + // πŸ†• v3.1: μ‚­μ œ 확인 λ‹€μ΄μ–Όλ‘œκ·Έ + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [pendingDeleteInfo, setPendingDeleteInfo] = useState<{ + cardId: string; + rowId: string; + contentRowId: string; + } | null>(null); // 초기 데이터 λ‘œλ“œ useEffect(() => { @@ -208,6 +235,425 @@ export function RepeatScreenModalComponent({ loadInitialData(); }, [dataSource, formData, groupedData, contentRows, grouping?.enabled, grouping?.groupByField]); + // πŸ†• v3.1: μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 λ‘œλ“œ + useEffect(() => { + const loadExternalTableData = async () => { + // contentRowsμ—μ„œ μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ†ŒμŠ€κ°€ μžˆλŠ” table νƒ€μž… ν–‰ μ°ΎκΈ° + const tableRowsWithExternalSource = contentRows.filter( + (row) => row.type === "table" && row.tableDataSource?.enabled + ); + + if (tableRowsWithExternalSource.length === 0) return; + if (groupedCardsData.length === 0 && cardsData.length === 0) return; + + const newExternalData: Record = {}; + + for (const contentRow of tableRowsWithExternalSource) { + const dataSourceConfig = contentRow.tableDataSource!; + const cards = groupedCardsData.length > 0 ? groupedCardsData : cardsData; + + for (const card of cards) { + const cardId = card._cardId; + const representativeData = (card as GroupedCardData)._representativeData || card; + + try { + // 쑰인 쑰건 생성 + const filters: Record = {}; + for (const condition of dataSourceConfig.joinConditions) { + const refValue = representativeData[condition.referenceKey]; + if (refValue !== undefined && refValue !== null) { + filters[condition.sourceKey] = refValue; + } + } + + if (Object.keys(filters).length === 0) { + console.warn(`[RepeatScreenModal] 쑰인 쑰건이 μ—†μŠ΅λ‹ˆλ‹€: ${contentRow.id}`); + continue; + } + + // API 호좜 - 메인 ν…Œμ΄λΈ” 데이터 + const response = await apiClient.post( + `/table-management/tables/${dataSourceConfig.sourceTable}/data`, + { + search: filters, + page: 1, + size: dataSourceConfig.limit || 100, + sort: dataSourceConfig.orderBy + ? { + column: dataSourceConfig.orderBy.column, + direction: dataSourceConfig.orderBy.direction, + } + : undefined, + } + ); + + if (response.data.success && response.data.data?.data) { + let tableData = response.data.data.data; + + console.log(`[RepeatScreenModal] μ†ŒμŠ€ ν…Œμ΄λΈ” 데이터 λ‘œλ“œ μ™„λ£Œ:`, { + sourceTable: dataSourceConfig.sourceTable, + rowCount: tableData.length, + sampleRow: tableData[0] ? Object.keys(tableData[0]) : [], + firstRowData: tableData[0], + }); + + // πŸ†• v3.3: μΆ”κ°€ 쑰인 ν…Œμ΄λΈ” 데이터 λ‘œλ“œ 및 병합 + if (dataSourceConfig.additionalJoins && dataSourceConfig.additionalJoins.length > 0) { + console.log(`[RepeatScreenModal] 쑰인 μ„€μ •:`, dataSourceConfig.additionalJoins); + tableData = await loadAndMergeJoinData(tableData, dataSourceConfig.additionalJoins); + console.log(`[RepeatScreenModal] 쑰인 ν›„ 데이터:`, { + rowCount: tableData.length, + sampleRow: tableData[0] ? Object.keys(tableData[0]) : [], + firstRowData: tableData[0], + }); + } + + // πŸ†• v3.4: ν•„ν„° 쑰건 적용 + if (dataSourceConfig.filterConfig?.enabled) { + const { filterField, filterType, referenceField, referenceSource } = dataSourceConfig.filterConfig; + + // 비ꡐ κ°’ κ°€μ Έμ˜€κΈ° + let referenceValue: any; + if (referenceSource === "formData") { + referenceValue = formData?.[referenceField]; + } else { + // representativeData + referenceValue = representativeData[referenceField]; + } + + if (referenceValue !== undefined && referenceValue !== null) { + tableData = tableData.filter((row: any) => { + const rowValue = row[filterField]; + if (filterType === "equals") { + return rowValue === referenceValue; + } else { + // notEquals + return rowValue !== referenceValue; + } + }); + + console.log(`[RepeatScreenModal] ν•„ν„° 적용: ${filterField} ${filterType} ${referenceValue}, κ²°κ³Ό: ${tableData.length}건`); + } + } + + const key = `${cardId}-${contentRow.id}`; + newExternalData[key] = tableData.map((row: any, idx: number) => ({ + _rowId: `ext-row-${cardId}-${contentRow.id}-${idx}-${Date.now()}`, + _originalData: { ...row }, + _isDirty: false, + _isNew: false, + ...row, + })); + } + } catch (error) { + console.error(`[RepeatScreenModal] μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 λ‘œλ“œ μ‹€νŒ¨:`, error); + } + } + } + + setExternalTableData((prev) => { + // 이전 데이터와 λ™μΌν•˜λ©΄ μ—…λ°μ΄νŠΈν•˜μ§€ μ•ŠμŒ (λ¬΄ν•œ 루프 λ°©μ§€) + const prevKeys = Object.keys(prev).sort().join(","); + const newKeys = Object.keys(newExternalData).sort().join(","); + if (prevKeys === newKeys) { + // ν‚€κ°€ κ°™μœΌλ©΄ 데이터 λ‚΄μš© 비ꡐ + const isSame = Object.keys(newExternalData).every( + (key) => JSON.stringify(prev[key]) === JSON.stringify(newExternalData[key]) + ); + if (isSame) return prev; + } + + // πŸ†• v3.2: μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 λ‘œλ“œ ν›„ 집계 μž¬κ³„μ‚° + // λΉ„λ™κΈ°μ μœΌλ‘œ μ²˜λ¦¬ν•˜μ—¬ λ¬΄ν•œ 루프 λ°©μ§€ + setTimeout(() => { + recalculateAggregationsWithExternalData(newExternalData); + }, 0); + + return newExternalData; + }); + }; + + loadExternalTableData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contentRows, groupedCardsData.length, cardsData.length]); + + // πŸ†• v3.3: μΆ”κ°€ 쑰인 ν…Œμ΄λΈ” 데이터 λ‘œλ“œ 및 병합 + const loadAndMergeJoinData = async ( + mainData: any[], + additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[] + ): Promise => { + if (mainData.length === 0) return mainData; + + // 각 쑰인 ν…Œμ΄λΈ”λ³„λ‘œ ν•„μš”ν•œ ν‚€ κ°’λ“€ μˆ˜μ§‘ + for (const joinConfig of additionalJoins) { + if (!joinConfig.joinTable || !joinConfig.sourceKey || !joinConfig.targetKey) continue; + + // 메인 λ°μ΄ν„°μ—μ„œ 쑰인 ν‚€ κ°’λ“€ μΆ”μΆœ + const joinKeyValues = [...new Set(mainData.map((row) => row[joinConfig.sourceKey]).filter(Boolean))]; + + if (joinKeyValues.length === 0) continue; + + try { + // 쑰인 ν…Œμ΄λΈ” 데이터 쑰회 + const joinResponse = await apiClient.post( + `/table-management/tables/${joinConfig.joinTable}/data`, + { + search: { [joinConfig.targetKey]: joinKeyValues }, + page: 1, + size: 1000, // μΆ©λΆ„νžˆ 큰 κ°’ + } + ); + + if (joinResponse.data.success && joinResponse.data.data?.data) { + const joinData = joinResponse.data.data.data; + + // 쑰인 데이터λ₯Ό 맡으둜 λ³€ν™˜ (λΉ λ₯Έ 쑰회λ₯Ό μœ„ν•΄) + const joinDataMap = new Map(); + for (const joinRow of joinData) { + joinDataMap.set(joinRow[joinConfig.targetKey], joinRow); + } + + // 메인 데이터에 쑰인 데이터 병합 + mainData = mainData.map((row) => { + const joinKey = row[joinConfig.sourceKey]; + const joinRow = joinDataMap.get(joinKey); + + if (joinRow) { + // 쑰인 ν…Œμ΄λΈ”μ˜ μ»¬λŸΌλ“€μ„ 메인 데이터에 μΆ”κ°€ (접두사 없이) + const mergedRow = { ...row }; + for (const [key, value] of Object.entries(joinRow)) { + // 이미 μ‘΄μž¬ν•˜λŠ” ν‚€κ°€ μ•„λ‹Œ κ²½μš°μ—λ§Œ μΆ”κ°€ (메인 ν…Œμ΄λΈ” μš°μ„ ) + if (!(key in mergedRow)) { + mergedRow[key] = value; + } else { + // μΆ©λŒν•˜λŠ” 경우 쑰인 ν…Œμ΄λΈ”λͺ…을 μ ‘λ‘μ‚¬λ‘œ μ‚¬μš© + mergedRow[`${joinConfig.joinTable}_${key}`] = value; + } + } + return mergedRow; + } + return row; + }); + } + } catch (error) { + console.error(`[RepeatScreenModal] 쑰인 ν…Œμ΄λΈ” 데이터 λ‘œλ“œ μ‹€νŒ¨ (${joinConfig.joinTable}):`, error); + } + } + + return mainData; + }; + + // πŸ†• v3.2: μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터가 λ‘œλ“œλœ ν›„ 집계 μž¬κ³„μ‚° + const recalculateAggregationsWithExternalData = (extData: Record) => { + if (!grouping?.aggregations || grouping.aggregations.length === 0) return; + if (groupedCardsData.length === 0) return; + + // μ™ΈλΆ€ ν…Œμ΄λΈ” 집계 λ˜λŠ” formulaκ°€ μžˆλŠ”μ§€ 확인 + const hasExternalAggregation = grouping.aggregations.some((agg) => { + const sourceType = agg.sourceType || "column"; + if (sourceType === "formula") return true; // formulaλŠ” μ™ΈλΆ€ ν…Œμ΄λΈ” μ°Έμ‘° κ°€λŠ₯ + if (sourceType === "column") { + const sourceTable = agg.sourceTable || dataSource?.sourceTable; + return sourceTable && sourceTable !== dataSource?.sourceTable; + } + return false; + }); + + if (!hasExternalAggregation) return; + + // contentRowsμ—μ„œ μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ†ŒμŠ€κ°€ μžˆλŠ” table νƒ€μž… ν–‰ μ°ΎκΈ° + const tableRowWithExternalSource = contentRows.find( + (row) => row.type === "table" && row.tableDataSource?.enabled + ); + + if (!tableRowWithExternalSource) return; + + // 각 μΉ΄λ“œμ˜ 집계 μž¬κ³„μ‚° + const updatedCards = groupedCardsData.map((card) => { + const key = `${card._cardId}-${tableRowWithExternalSource.id}`; + const externalRows = extData[key] || []; + + // 집계 μž¬κ³„μ‚° + const newAggregations: Record = {}; + + grouping.aggregations!.forEach((agg) => { + const sourceType = agg.sourceType || "column"; + + if (sourceType === "column") { + const sourceTable = agg.sourceTable || dataSource?.sourceTable; + const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; + + if (isExternalTable) { + // μ™ΈλΆ€ ν…Œμ΄λΈ” 집계 + newAggregations[agg.resultField] = calculateColumnAggregation( + externalRows, + agg.sourceField || "", + agg.type || "sum" + ); + } else { + // κΈ°λ³Έ ν…Œμ΄λΈ” 집계 (κΈ°μ‘΄ κ°’ μœ μ§€) + newAggregations[agg.resultField] = card._aggregations[agg.resultField] || + calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum"); + } + } else if (sourceType === "formula" && agg.formula) { + // 가상 집계 (연산식) - μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 ν¬ν•¨ν•˜μ—¬ μž¬κ³„μ‚° + newAggregations[agg.resultField] = evaluateFormulaWithContext( + agg.formula, + card._representativeData, + card._rows, + externalRows, + newAggregations // 이전 집계 κ²°κ³Ό μ°Έμ‘° + ); + } + }); + + return { + ...card, + _aggregations: newAggregations, + }; + }); + + // λ³€κ²½λœ κ²½μš°μ—λ§Œ μ—…λ°μ΄νŠΈ (λ¬΄ν•œ 루프 λ°©μ§€) + setGroupedCardsData((prev) => { + const hasChanges = updatedCards.some((card, idx) => { + const prevCard = prev[idx]; + if (!prevCard) return true; + return JSON.stringify(card._aggregations) !== JSON.stringify(prevCard._aggregations); + }); + return hasChanges ? updatedCards : prev; + }); + }; + + // πŸ†• v3.1: μ™ΈλΆ€ ν…Œμ΄λΈ” ν–‰ μΆ”κ°€ + const handleAddExternalRow = (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { + const key = `${cardId}-${contentRowId}`; + const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId); + const representativeData = (card as GroupedCardData)?._representativeData || card || {}; + + // κΈ°λ³Έκ°’ 생성 + const newRowData: Record = { + _rowId: `new-row-${Date.now()}`, + _originalData: {}, + _isDirty: true, + _isNew: true, + }; + + // πŸ†• v3.5: μΉ΄λ“œ λŒ€ν‘œ λ°μ΄ν„°μ—μ„œ 쑰인 ν…Œμ΄λΈ” 컬럼 κ°’ μžλ™ μ±„μš°κΈ° + // tableColumnsμ—μ„œ μ •μ˜λœ ν•„λ“œλ“€ 쀑 representativeData에 μžˆλŠ” 값을 μžλ™μœΌλ‘œ 채움 + if (contentRow.tableColumns) { + for (const col of contentRow.tableColumns) { + // representativeData에 ν•΄λ‹Ή ν•„λ“œκ°€ 있으면 μžλ™μœΌλ‘œ 채움 + if (representativeData[col.field] !== undefined && representativeData[col.field] !== null) { + newRowData[col.field] = representativeData[col.field]; + } + } + } + + // πŸ†• v3.5: 쑰인 쑰건의 ν‚€ 값도 μžλ™μœΌλ‘œ 채움 (예: sales_order_id) + if (contentRow.tableDataSource?.joinConditions) { + for (const condition of contentRow.tableDataSource.joinConditions) { + // sourceKeyλŠ” μ†ŒμŠ€ ν…Œμ΄λΈ”(예: shipment_plan)의 컬럼 + // referenceKeyλŠ” μΉ΄λ“œ λŒ€ν‘œ λ°μ΄ν„°μ˜ 컬럼 (예: id) + const refValue = representativeData[condition.referenceKey]; + if (refValue !== undefined && refValue !== null) { + newRowData[condition.sourceKey] = refValue; + } + } + } + + // newRowDefaults 적용 (μ‚¬μš©μž μ •μ˜ 기본값이 μš°μ„ ) + if (contentRow.tableCrud?.newRowDefaults) { + for (const [field, template] of Object.entries(contentRow.tableCrud.newRowDefaults)) { + // {fieldName} ν˜•μ‹μ˜ ν…œν”Œλ¦Ώ μΉ˜ν™˜ + let value = template; + const matches = template.match(/\{(\w+)\}/g); + if (matches) { + for (const match of matches) { + const fieldName = match.slice(1, -1); + value = value.replace(match, String(representativeData[fieldName] || "")); + } + } + newRowData[field] = value; + } + } + + console.log("[RepeatScreenModal] μƒˆ ν–‰ μΆ”κ°€:", { + cardId, + contentRowId, + representativeData, + newRowData, + }); + + setExternalTableData((prev) => { + const newData = { + ...prev, + [key]: [...(prev[key] || []), newRowData], + }; + + // πŸ†• v3.5: μƒˆ ν–‰ μΆ”κ°€ μ‹œ 집계 μž¬κ³„μ‚° + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); + }; + + // πŸ†• v3.1: μ™ΈλΆ€ ν…Œμ΄λΈ” ν–‰ μ‚­μ œ μš”μ²­ + const handleDeleteExternalRowRequest = (cardId: string, rowId: string, contentRowId: string, contentRow: CardContentRowConfig) => { + if (contentRow.tableCrud?.deleteConfirm?.enabled !== false) { + // μ‚­μ œ 확인 νŒμ—… ν‘œμ‹œ + setPendingDeleteInfo({ cardId, rowId, contentRowId }); + setDeleteConfirmOpen(true); + } else { + // λ°”λ‘œ μ‚­μ œ + handleDeleteExternalRow(cardId, rowId, contentRowId); + } + }; + + // πŸ†• v3.1: μ™ΈλΆ€ ν…Œμ΄λΈ” ν–‰ μ‚­μ œ μ‹€ν–‰ + const handleDeleteExternalRow = (cardId: string, rowId: string, contentRowId: string) => { + const key = `${cardId}-${contentRowId}`; + setExternalTableData((prev) => { + const newData = { + ...prev, + [key]: (prev[key] || []).filter((row) => row._rowId !== rowId), + }; + + // πŸ†• v3.5: ν–‰ μ‚­μ œ μ‹œ 집계 μž¬κ³„μ‚° + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); + setDeleteConfirmOpen(false); + setPendingDeleteInfo(null); + }; + + // πŸ†• v3.1: μ™ΈλΆ€ ν…Œμ΄λΈ” ν–‰ 데이터 λ³€κ²½ + const handleExternalRowDataChange = (cardId: string, contentRowId: string, rowId: string, field: string, value: any) => { + const key = `${cardId}-${contentRowId}`; + + // 데이터 μ—…λ°μ΄νŠΈ + setExternalTableData((prev) => { + const newData = { + ...prev, + [key]: (prev[key] || []).map((row) => + row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row + ), + }; + + // πŸ†• v3.5: 데이터 λ³€κ²½ μ‹œ 집계 μ‹€μ‹œκ°„ μž¬κ³„μ‚° + // setTimeout으둜 비동기 μ²˜λ¦¬ν•˜μ—¬ μƒνƒœ μ—…λ°μ΄νŠΈ ν›„ μž¬κ³„μ‚° + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); + }; + // κ·Έλ£Ήν™”λœ 데이터 처리 const processGroupedData = (data: any[], groupingConfig: typeof grouping): GroupedCardData[] => { if (!groupingConfig?.enabled) { @@ -240,14 +686,6 @@ export function RepeatScreenModalComponent({ let cardIndex = 0; groupMap.forEach((rows, groupKey) => { - // 집계 계산 - const aggregations: Record = {}; - if (groupingConfig.aggregations) { - groupingConfig.aggregations.forEach((agg) => { - aggregations[agg.resultField] = calculateAggregation(rows, agg); - }); - } - // ν–‰ 데이터 생성 const cardRows: CardRowData[] = rows.map((row, idx) => ({ _rowId: `row-${cardIndex}-${idx}-${Date.now()}`, @@ -256,13 +694,56 @@ export function RepeatScreenModalComponent({ ...row, })); + const representativeData = rows[0] || {}; + + // πŸ†• v3.2: 집계 계산 (μˆœμ„œλŒ€λ‘œ - 이전 집계 κ²°κ³Ό μ°Έμ‘° κ°€λŠ₯) + // 1단계: κΈ°λ³Έ ν…Œμ΄λΈ” 컬럼 μ§‘κ³„λ§Œ (μ™ΈλΆ€ ν…Œμ΄λΈ” λ°μ΄ν„°λŠ” 아직 μ—†μŒ) + const aggregations: Record = {}; + if (groupingConfig.aggregations) { + groupingConfig.aggregations.forEach((agg) => { + const sourceType = agg.sourceType || "column"; + + if (sourceType === "column") { + // 컬럼 집계 (κΈ°λ³Έ ν…Œμ΄λΈ”λ§Œ - μ™ΈλΆ€ ν…Œμ΄λΈ”μ€ λ‚˜μ€‘μ— 처리) + const sourceTable = agg.sourceTable || dataSource?.sourceTable; + const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; + + if (!isExternalTable) { + // κΈ°λ³Έ ν…Œμ΄λΈ” 집계 + aggregations[agg.resultField] = calculateColumnAggregation( + rows, + agg.sourceField || "", + agg.type || "sum" + ); + } else { + // μ™ΈλΆ€ ν…Œμ΄λΈ” μ§‘κ³„λŠ” λ‚˜μ€‘μ— 계산 (placeholder) + aggregations[agg.resultField] = 0; + } + } else if (sourceType === "formula") { + // 가상 집계 (연산식) - μ™ΈλΆ€ ν…Œμ΄λΈ” 없이 λ¨Όμ € 계산 μ‹œλ„ + // μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터가 ν•„μš”ν•œ 경우 λ‚˜μ€‘μ— μž¬κ³„μ‚°λ¨ + if (agg.formula) { + aggregations[agg.resultField] = evaluateFormulaWithContext( + agg.formula, + representativeData, + rows, + [], // μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ—†μŒ + aggregations // 이전 집계 κ²°κ³Ό μ°Έμ‘° + ); + } else { + aggregations[agg.resultField] = 0; + } + } + }); + } + result.push({ _cardId: `grouped-card-${cardIndex}-${Date.now()}`, _groupKey: groupKey, _groupField: groupByField || "", _aggregations: aggregations, _rows: cardRows, - _representativeData: rows[0] || {}, + _representativeData: representativeData, }); cardIndex++; @@ -271,11 +752,15 @@ export function RepeatScreenModalComponent({ return result; }; - // 집계 계산 - const calculateAggregation = (rows: any[], agg: AggregationConfig): number => { - const values = rows.map((row) => Number(row[agg.sourceField]) || 0); + // 집계 계산 (컬럼 μ§‘κ³„μš©) + const calculateColumnAggregation = ( + rows: any[], + sourceField: string, + type: "sum" | "count" | "avg" | "min" | "max" + ): number => { + const values = rows.map((row) => Number(row[sourceField]) || 0); - switch (agg.type) { + switch (type) { case "sum": return values.reduce((a, b) => a + b, 0); case "count": @@ -291,6 +776,175 @@ export function RepeatScreenModalComponent({ } }; + // πŸ†• v3.2: 집계 계산 (닀쀑 ν…Œμ΄λΈ” 및 formula 지원) + const calculateAggregation = ( + agg: AggregationConfig, + cardRows: any[], // κΈ°λ³Έ ν…Œμ΄λΈ” ν–‰λ“€ + externalRows: any[], // μ™ΈλΆ€ ν…Œμ΄λΈ” ν–‰λ“€ + previousAggregations: Record, // 이전 집계 κ²°κ³Όλ“€ + representativeData: Record // μΉ΄λ“œ λŒ€ν‘œ 데이터 + ): number => { + const sourceType = agg.sourceType || "column"; + + if (sourceType === "column") { + // 컬럼 집계 + const sourceTable = agg.sourceTable || dataSource?.sourceTable; + const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; + + // μ™ΈλΆ€ ν…Œμ΄λΈ”μΈ 경우 externalRows μ‚¬μš©, μ•„λ‹ˆλ©΄ cardRows μ‚¬μš© + const targetRows = isExternalTable ? externalRows : cardRows; + + return calculateColumnAggregation( + targetRows, + agg.sourceField || "", + agg.type || "sum" + ); + } else if (sourceType === "formula") { + // 가상 집계 (연산식) + if (!agg.formula) return 0; + + return evaluateFormulaWithContext( + agg.formula, + representativeData, + cardRows, + externalRows, + previousAggregations + ); + } + + return 0; + }; + + // πŸ†• v3.1: 집계 ν‘œμ‹œκ°’ 계산 (formula, external λ“± 지원) + const calculateAggregationDisplayValue = ( + aggField: AggregationDisplayConfig, + card: GroupedCardData + ): number | string => { + const sourceType = aggField.sourceType || "aggregation"; + + switch (sourceType) { + case "aggregation": + // κΈ°μ‘΄ 집계 κ²°κ³Ό μ°Έμ‘° + return card._aggregations?.[aggField.aggregationResultField || ""] || 0; + + case "formula": + // 컬럼 κ°„ μ—°μ‚° + if (!aggField.formula) return 0; + return evaluateFormula(aggField.formula, card._representativeData, card._rows); + + case "external": + // μ™ΈλΆ€ ν…Œμ΄λΈ” κ°’ (별도 λ‘œλ“œ ν•„μš” - ν˜„μž¬λŠ” placeholder) + // TODO: μ™ΈλΆ€ ν…Œμ΄λΈ” κ°’ λ‘œλ“œ κ΅¬ν˜„ + return 0; + + case "externalFormula": + // μ™ΈλΆ€ ν…Œμ΄λΈ” + μ—°μ‚° (별도 λ‘œλ“œ ν•„μš” - ν˜„μž¬λŠ” placeholder) + // TODO: μ™ΈλΆ€ ν…Œμ΄λΈ” κ°’ λ‘œλ“œ ν›„ μ—°μ‚° κ΅¬ν˜„ + return 0; + + default: + return 0; + } + }; + + // πŸ†• v3.2: 연산식 평가 (닀쀑 ν…Œμ΄λΈ”, 이전 집계 κ²°κ³Ό μ°Έμ‘° 지원) + const evaluateFormulaWithContext = ( + formula: string, + representativeData: Record, + cardRows: any[], // κΈ°λ³Έ ν…Œμ΄λΈ” ν–‰λ“€ + externalRows: any[], // μ™ΈλΆ€ ν…Œμ΄λΈ” ν–‰λ“€ + previousAggregations: Record // 이전 집계 κ²°κ³Όλ“€ + ): number => { + try { + let expression = formula; + + // 1. μ™ΈλΆ€ ν…Œμ΄λΈ” 집계 ν•¨μˆ˜ 처리: SUM_EXT({field}), COUNT_EXT({field}) λ“± + const extAggFunctions = ["SUM_EXT", "COUNT_EXT", "AVG_EXT", "MIN_EXT", "MAX_EXT"]; + for (const fn of extAggFunctions) { + const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g"); + expression = expression.replace(regex, (match, fieldName) => { + if (!externalRows || externalRows.length === 0) return "0"; + const values = externalRows.map((row) => Number(row[fieldName]) || 0); + const baseFn = fn.replace("_EXT", ""); + switch (baseFn) { + case "SUM": + return String(values.reduce((a, b) => a + b, 0)); + case "COUNT": + return String(values.length); + case "AVG": + return String(values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0); + case "MIN": + return String(values.length > 0 ? Math.min(...values) : 0); + case "MAX": + return String(values.length > 0 ? Math.max(...values) : 0); + default: + return "0"; + } + }); + } + + // 2. κΈ°λ³Έ ν…Œμ΄λΈ” 집계 ν•¨μˆ˜ 처리: SUM({field}), COUNT({field}) λ“± + const aggFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; + for (const fn of aggFunctions) { + // SUM_EXTλŠ” 이미 μ²˜λ¦¬ν–ˆμœΌλ―€λ‘œ μ œμ™Έ + const regex = new RegExp(`(? { + if (!cardRows || cardRows.length === 0) return "0"; + const values = cardRows.map((row) => Number(row[fieldName]) || 0); + switch (fn) { + case "SUM": + return String(values.reduce((a, b) => a + b, 0)); + case "COUNT": + return String(values.length); + case "AVG": + return String(values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0); + case "MIN": + return String(values.length > 0 ? Math.min(...values) : 0); + case "MAX": + return String(values.length > 0 ? Math.max(...values) : 0); + default: + return "0"; + } + }); + } + + // 3. λ‹¨μˆœ ν•„λ“œ μ°Έμ‘° μΉ˜ν™˜ (이전 집계 κ²°κ³Ό λ˜λŠ” λŒ€ν‘œ 데이터) + const fieldRegex = /\{(\w+)\}/g; + expression = expression.replace(fieldRegex, (match, fieldName) => { + // λ¨Όμ € 이전 집계 κ²°κ³Όμ—μ„œ μ°ΎκΈ° + if (previousAggregations && fieldName in previousAggregations) { + return String(previousAggregations[fieldName]); + } + // λŒ€ν‘œ λ°μ΄ν„°μ—μ„œ κ°’ κ°€μ Έμ˜€κΈ° + const value = representativeData[fieldName]; + return String(Number(value) || 0); + }); + + // 4. μ•ˆμ „ν•œ μˆ˜μ‹ 평가 (μ‚¬μΉ™μ—°μ‚°λ§Œ ν—ˆμš©) + // ν—ˆμš© 문자: 숫자, μ†Œμˆ˜μ , 사칙연산, κ΄„ν˜Έ, 곡백 + if (!/^[\d\s+\-*/().]+$/.test(expression)) { + console.warn("[RepeatScreenModal] ν—ˆμš©λ˜μ§€ μ•ŠλŠ” 연산식:", expression); + return 0; + } + + // eval λŒ€μ‹  Function μ‚¬μš© (더 μ•ˆμ „) + const result = new Function(`return ${expression}`)(); + return Number(result) || 0; + } catch (error) { + console.error("[RepeatScreenModal] 연산식 평가 μ‹€νŒ¨:", formula, error); + return 0; + } + }; + + // λ ˆκ±°μ‹œ ν˜Έν™˜: κΈ°μ‘΄ evaluateFormula μœ μ§€ + const evaluateFormula = ( + formula: string, + representativeData: Record, + rows?: any[] + ): number => { + return evaluateFormulaWithContext(formula, representativeData, rows || [], [], {}); + }; + // μΉ΄λ“œ 데이터 λ‘œλ“œ (μ†ŒμŠ€ 섀정에 따라) const loadCardData = async (originalData: any): Promise> => { const cardData: Record = {}; @@ -401,12 +1055,16 @@ export function RepeatScreenModalComponent({ setIsSaving(true); try { + // κΈ°μ‘΄ 데이터 μ €μž₯ if (cardMode === "withTable") { await saveGroupedData(); } else { await saveSimpleData(); } + // πŸ†• v3.1: μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ €μž₯ + await saveExternalTableData(); + alert("μ €μž₯λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); } catch (error: any) { console.error("μ €μž₯ μ‹€νŒ¨:", error); @@ -416,6 +1074,92 @@ export function RepeatScreenModalComponent({ } }; + // πŸ†• v3.1: μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ €μž₯ + const saveExternalTableData = async () => { + const savePromises: Promise[] = []; + + for (const [key, rows] of Object.entries(externalTableData)) { + // key ν˜•μ‹: cardId-contentRowId + const [cardId, contentRowId] = key.split("-").slice(0, 2); + const contentRow = contentRows.find((r) => r.id === contentRowId || key.includes(r.id)); + + if (!contentRow?.tableDataSource?.enabled) continue; + + const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; + const dirtyRows = rows.filter((row) => row._isDirty); + + for (const row of dirtyRows) { + const { _rowId, _originalData, _isDirty, _isNew, ...dataToSave } = row; + + if (_isNew) { + // INSERT + savePromises.push( + apiClient.post(`/table-management/tables/${targetTable}/data`, dataToSave).then(() => {}) + ); + } else if (_originalData?.id) { + // UPDATE + savePromises.push( + apiClient.put(`/table-management/tables/${targetTable}/data/${_originalData.id}`, dataToSave).then(() => {}) + ); + } + } + } + + await Promise.all(savePromises); + + // μ €μž₯ ν›„ dirty ν”Œλž˜κ·Έ μ΄ˆκΈ°ν™” + setExternalTableData((prev) => { + const updated: Record = {}; + for (const [key, rows] of Object.entries(prev)) { + updated[key] = rows.map((row) => ({ + ...row, + _isDirty: false, + _isNew: false, + _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined }, + })); + } + return updated; + }); + }; + + // πŸ†• v3.1: Footer λ²„νŠΌ 클릭 ν•Έλ“€λŸ¬ + const handleFooterButtonClick = async (btn: FooterButtonConfig) => { + switch (btn.action) { + case "save": + await handleSaveAll(); + break; + case "cancel": + case "close": + // λͺ¨λ‹¬ λ‹«κΈ° 이벀트 λ°œμƒ + window.dispatchEvent(new CustomEvent("closeScreenModal")); + break; + case "reset": + // 데이터 μ΄ˆκΈ°ν™” + if (confirm("λ³€κ²½ 사항을 λͺ¨λ‘ μ·¨μ†Œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?")) { + // μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ΄ˆκΈ°ν™” + setExternalTableData({}); + // κΈ°μ‘΄ 데이터 μž¬λ‘œλ“œ + setCardsData([]); + setGroupedCardsData([]); + } + break; + case "custom": + // μ»€μŠ€ν…€ μ•‘μ…˜ 이벀트 λ°œμƒ + if (btn.customAction) { + window.dispatchEvent( + new CustomEvent("repeatScreenModalCustomAction", { + detail: { + actionType: btn.customAction.type, + config: btn.customAction.config, + componentId: component?.id, + }, + }) + ); + } + break; + } + }; + // Simple λͺ¨λ“œ μ €μž₯ const saveSimpleData = async () => { const dirtyCards = cardsData.filter((card) => card._isDirty); @@ -536,11 +1280,21 @@ export function RepeatScreenModalComponent({ // μˆ˜μ • μ—¬λΆ€ 확인 const hasDirtyData = useMemo(() => { + // κΈ°μ‘΄ 데이터 μˆ˜μ • μ—¬λΆ€ + let hasBaseDirty = false; if (cardMode === "withTable") { - return groupedCardsData.some((card) => card._rows.some((row) => row._isDirty)); + hasBaseDirty = groupedCardsData.some((card) => card._rows.some((row) => row._isDirty)); + } else { + hasBaseDirty = cardsData.some((c) => c._isDirty); } - return cardsData.some((c) => c._isDirty); - }, [cardMode, cardsData, groupedCardsData]); + + // πŸ†• v3.1: μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μˆ˜μ • μ—¬λΆ€ + const hasExternalDirty = Object.values(externalTableData).some((rows) => + rows.some((row) => row._isDirty) + ); + + return hasBaseDirty || hasExternalDirty; + }, [cardMode, cardsData, groupedCardsData, externalTableData]); // λ””μžμΈ λͺ¨λ“œ λ Œλ”λ§ if (isDesignMode) { @@ -710,7 +1464,105 @@ export function RepeatScreenModalComponent({ {useNewLayout ? ( contentRows.map((contentRow, rowIndex) => (
- {renderContentRow(contentRow, card, grouping?.aggregations || [], handleRowDataChange)} + {contentRow.type === "table" && contentRow.tableDataSource?.enabled ? ( + // πŸ†• v3.1: μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ†ŒμŠ€ μ‚¬μš© +
+ {contentRow.tableTitle && ( +
+ {contentRow.tableTitle} + {contentRow.tableCrud?.allowCreate && ( + + )} +
+ )} + {!contentRow.tableTitle && contentRow.tableCrud?.allowCreate && ( +
+ +
+ )} + + {contentRow.showTableHeader !== false && ( + + + {(contentRow.tableColumns || []).map((col) => ( + + {col.label} + + ))} + {contentRow.tableCrud?.allowDelete && ( + μ‚­μ œ + )} + + + )} + + {(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? ( + + + 데이터가 μ—†μŠ΅λ‹ˆλ‹€. + + + ) : ( + (externalTableData[`${card._cardId}-${contentRow.id}`] || []).map((row) => ( + + {(contentRow.tableColumns || []).map((col) => ( + + {renderTableCell(col, row, (value) => + handleExternalRowDataChange(card._cardId, contentRow.id, row._rowId, col.field, value) + )} + + ))} + {contentRow.tableCrud?.allowDelete && ( + + + + )} + + )) + )} + +
+
+ ) : ( + // κΈ°μ‘΄ renderContentRow μ‚¬μš© + renderContentRow(contentRow, card, grouping?.aggregations || [], handleRowDataChange) + )}
)) ) : ( @@ -782,20 +1634,74 @@ export function RepeatScreenModalComponent({ ))}
- {/* μ €μž₯ λ²„νŠΌ */} - {groupedCardsData.length > 0 && ( -
- + {/* πŸ†• v3.1: Footer λ²„νŠΌ μ˜μ—­ */} + {footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? ( +
+ {footerConfig.buttons.map((btn) => ( + + ))}
- )} + ) : null} {/* 데이터 μ—†μŒ */} {groupedCardsData.length === 0 && !isLoading && (
ν‘œμ‹œν•  데이터가 μ—†μŠ΅λ‹ˆλ‹€.
)} + + {/* πŸ†• v3.1: μ‚­μ œ 확인 λ‹€μ΄μ–Όλ‘œκ·Έ */} + + + + μ‚­μ œ 확인 + + 이 행을 μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ? 이 μž‘μ—…μ€ 되돌릴 수 μ—†μŠ΅λ‹ˆλ‹€. + + + + μ·¨μ†Œ + { + if (pendingDeleteInfo) { + handleDeleteExternalRow( + pendingDeleteInfo.cardId, + pendingDeleteInfo.rowId, + pendingDeleteInfo.contentRowId + ); + } + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + μ‚­μ œ + + + +
); } @@ -852,15 +1758,40 @@ export function RepeatScreenModalComponent({ ))}
- {/* μ €μž₯ λ²„νŠΌ */} - {cardsData.length > 0 && ( -
- + {/* πŸ†• v3.1: Footer λ²„νŠΌ μ˜μ—­ */} + {footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? ( +
+ {footerConfig.buttons.map((btn) => ( + + ))}
- )} + ) : null} {/* 데이터 μ—†μŒ */} {cardsData.length === 0 && !isLoading && ( diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx index ab8c962d..da7088a9 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx @@ -6,17 +6,15 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { Plus, X, GripVertical, Check, ChevronsUpDown, Table, Layers } from "lucide-react"; +import { Plus, X, GripVertical, Check, ChevronsUpDown, Table, Layers, ChevronUp, ChevronDown } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { RepeatScreenModalProps, CardRowConfig, CardColumnConfig, ColumnSourceConfig, ColumnTargetConfig, - DataSourceConfig, GroupingConfig, AggregationConfig, TableLayoutConfig, @@ -84,14 +82,14 @@ function SourceColumnSelector({ variant="outline" role="combobox" aria-expanded={open} - className="h-6 w-full justify-between text-[10px]" + className="h-6 w-full justify-between text-[10px] min-w-0 shrink" disabled={!sourceTable || isLoading} > {isLoading ? "..." : displayText} - + @@ -315,75 +313,583 @@ function CardTitleEditor({ ); } +// πŸ†• v3.2: μ‹œκ°μ  μˆ˜μ‹ λΉŒλ” +interface FormulaToken { + id: string; + type: "aggregation" | "column" | "operator" | "number"; + // aggregation: 이전 집계 κ²°κ³Ό μ°Έμ‘° + aggregationField?: string; + // column: ν…Œμ΄λΈ” 컬럼 집계 + table?: string; + column?: string; + aggFunction?: "SUM" | "COUNT" | "AVG" | "MIN" | "MAX" | "SUM_EXT" | "COUNT_EXT" | "AVG_EXT" | "MIN_EXT" | "MAX_EXT" | "VALUE"; + isExternal?: boolean; + // operator: μ—°μ‚°μž + operator?: "+" | "-" | "*" | "/" | "(" | ")"; + // number: 숫자 + value?: number; +} + +function FormulaBuilder({ + formula, + sourceTable, + allTables, + referenceableAggregations, + onChange, +}: { + formula: string; + sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + referenceableAggregations: AggregationConfig[]; + onChange: (formula: string) => void; +}) { + // μˆ˜μ‹ 토큰 μƒνƒœ + const [tokens, setTokens] = useState([]); + + // μƒˆ 토큰 μΆ”κ°€μš© μƒνƒœ + const [newTokenType, setNewTokenType] = useState<"aggregation" | "column">("aggregation"); + const [newTokenTable, setNewTokenTable] = useState(sourceTable || ""); + const [newTokenColumn, setNewTokenColumn] = useState(""); + const [newTokenAggFunction, setNewTokenAggFunction] = useState("SUM"); + const [newTokenAggField, setNewTokenAggField] = useState(""); + + // formula λ¬Έμžμ—΄μ—μ„œ 토큰 νŒŒμ‹± (μ΄ˆκΈ°ν™”μš©) + useEffect(() => { + if (!formula) { + setTokens([]); + return; + } + + // κ°„λ‹¨ν•œ νŒŒμ‹±: κΈ°μ‘΄ formulaκ°€ 있으면 ν† ν°μœΌλ‘œ λ³€ν™˜ μ‹œλ„ + const parsed = parseFormulaToTokens(formula, sourceTable); + if (parsed.length > 0) { + setTokens(parsed); + } + }, []); + + // 토큰을 formula λ¬Έμžμ—΄λ‘œ λ³€ν™˜ + const tokensToFormula = (tokenList: FormulaToken[]): string => { + return tokenList.map((token) => { + switch (token.type) { + case "aggregation": + return `{${token.aggregationField}}`; + case "column": + if (token.aggFunction === "VALUE") { + return `{${token.column}}`; + } + return `${token.aggFunction}({${token.column}})`; + case "operator": + return ` ${token.operator} `; + case "number": + return String(token.value); + default: + return ""; + } + }).join(""); + }; + + // formula λ¬Έμžμ—΄μ—μ„œ 토큰 νŒŒμ‹± (κ°„λ‹¨ν•œ 버전) + const parseFormulaToTokens = (formulaStr: string, defaultTable: string): FormulaToken[] => { + const result: FormulaToken[] = []; + // κ°„λ‹¨ν•œ νŒŒμ‹± - λ³΅μž‘ν•œ κ²½μš°λŠ” μˆ˜λ™ μž…λ ₯ λͺ¨λ“œλ‘œ μ „ν™˜ + // 이 ν•¨μˆ˜λŠ” κΈ°μ‘΄ formulaκ°€ μžˆμ„ λ•Œ μ΅œλŒ€ν•œ νŒŒμ‹± μ‹œλ„ + const parts = formulaStr.split(/(\s*[+\-*/()]\s*)/); + + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) continue; + + // μ—°μ‚°μž + if (["+", "-", "*", "/", "(", ")"].includes(trimmed)) { + result.push({ + id: `op-${Date.now()}-${Math.random()}`, + type: "operator", + operator: trimmed as FormulaToken["operator"], + }); + continue; + } + + // 집계 ν•¨μˆ˜: SUM({column}), SUM_EXT({column}) + const aggMatch = trimmed.match(/^(SUM|COUNT|AVG|MIN|MAX)(_EXT)?\(\{(\w+)\}\)$/); + if (aggMatch) { + result.push({ + id: `col-${Date.now()}-${Math.random()}`, + type: "column", + table: aggMatch[2] ? "" : defaultTable, // _EXTλ©΄ μ™ΈλΆ€ ν…Œμ΄λΈ” + column: aggMatch[3], + aggFunction: (aggMatch[1] + (aggMatch[2] || "")) as FormulaToken["aggFunction"], + isExternal: !!aggMatch[2], + }); + continue; + } + + // ν•„λ“œ μ°Έμ‘°: {fieldName} + const fieldMatch = trimmed.match(/^\{(\w+)\}$/); + if (fieldMatch) { + result.push({ + id: `agg-${Date.now()}-${Math.random()}`, + type: "aggregation", + aggregationField: fieldMatch[1], + }); + continue; + } + + // 숫자 + const num = parseFloat(trimmed); + if (!isNaN(num)) { + result.push({ + id: `num-${Date.now()}-${Math.random()}`, + type: "number", + value: num, + }); + } + } + + return result; + }; + + // 토큰 μΆ”κ°€ + const addToken = (token: FormulaToken) => { + const newTokens = [...tokens, token]; + setTokens(newTokens); + onChange(tokensToFormula(newTokens)); + }; + + // 토큰 μ‚­μ œ + const removeToken = (tokenId: string) => { + const newTokens = tokens.filter((t) => t.id !== tokenId); + setTokens(newTokens); + onChange(tokensToFormula(newTokens)); + }; + + // μ—°μ‚°μž μΆ”κ°€ + const addOperator = (op: FormulaToken["operator"]) => { + addToken({ + id: `op-${Date.now()}`, + type: "operator", + operator: op, + }); + }; + + // 집계 μ°Έμ‘° μΆ”κ°€ + const addAggregationRef = () => { + if (!newTokenAggField) return; + addToken({ + id: `agg-${Date.now()}`, + type: "aggregation", + aggregationField: newTokenAggField, + }); + setNewTokenAggField(""); + }; + + // 컬럼 집계 μΆ”κ°€ + const addColumnAgg = () => { + if (!newTokenColumn) return; + const isExternal = newTokenTable !== sourceTable; + let aggFunc = newTokenAggFunction; + + // μ™ΈλΆ€ ν…Œμ΄λΈ”μ΄λ©΄ _EXT 뢙이기 + if (isExternal && aggFunc && !aggFunc.endsWith("_EXT") && aggFunc !== "VALUE") { + aggFunc = (aggFunc + "_EXT") as FormulaToken["aggFunction"]; + } + + addToken({ + id: `col-${Date.now()}`, + type: "column", + table: newTokenTable, + column: newTokenColumn, + aggFunction: aggFunc, + isExternal, + }); + setNewTokenColumn(""); + }; + + // 토큰 ν‘œμ‹œ ν…μŠ€νŠΈ + const getTokenDisplay = (token: FormulaToken): string => { + switch (token.type) { + case "aggregation": + const refAgg = referenceableAggregations.find((a) => a.resultField === token.aggregationField); + return refAgg?.label || token.aggregationField || ""; + case "column": + if (token.aggFunction === "VALUE") { + return `${token.column}`; + } + return `${token.aggFunction}(${token.column})`; + case "operator": + return token.operator || ""; + case "number": + return String(token.value); + default: + return ""; + } + }; + + // 토큰 λ°°μ§€ 색상 + const getTokenBadgeClass = (token: FormulaToken): string => { + switch (token.type) { + case "aggregation": + return "bg-blue-100 text-blue-700 border-blue-200"; + case "column": + return token.isExternal + ? "bg-orange-100 text-orange-700 border-orange-200" + : "bg-green-100 text-green-700 border-green-200"; + case "operator": + return "bg-gray-100 text-gray-700 border-gray-200"; + case "number": + return "bg-purple-100 text-purple-700 border-purple-200"; + default: + return ""; + } + }; + + return ( +
+ {/* ν˜„μž¬ μˆ˜μ‹ ν‘œμ‹œ */} +
+ +
+ {tokens.length === 0 ? ( + μ•„λž˜μ—μ„œ μš”μ†Œλ₯Ό μΆ”κ°€ν•˜μ„Έμš” + ) : ( + tokens.map((token) => ( + removeToken(token.id)} + title="ν΄λ¦­ν•˜μ—¬ μ‚­μ œ" + > + {getTokenDisplay(token)} + + + )) + )} +
+ {/* μƒμ„±λœ μˆ˜μ‹ 미리보기 */} + {tokens.length > 0 && ( +

+ {tokensToFormula(tokens)} +

+ )} +
+ + {/* μ—°μ‚°μž λ²„νŠΌ */} +
+ +
+ {["+", "-", "*", "/", "(", ")"].map((op) => ( + + ))} +
+
+ + {/* 집계 μ°Έμ‘° μΆ”κ°€ */} + {referenceableAggregations.length > 0 && ( +
+ +
+ μ°Έμ‘°ν•  집계 선택 +
+ + +
+
+
+ )} + + {/* ν…Œμ΄λΈ” 컬럼 집계 μΆ”κ°€ */} +
+ + + {/* ν…Œμ΄λΈ” 선택 */} +
+ ν…Œμ΄λΈ” + +
+ + {/* 컬럼 선택 */} +
+ 컬럼 + +
+ + {/* 집계 ν•¨μˆ˜ 및 μΆ”κ°€ λ²„νŠΌ */} +
+
+ 집계 ν•¨μˆ˜ + +
+
+ +
+
+ + {newTokenTable !== sourceTable && newTokenTable && ( +

μ™ΈλΆ€ ν…Œμ΄λΈ”: _EXT ν•¨μˆ˜ μ‚¬μš©

+ )} +
+ + {/* μˆ˜λ™ μž…λ ₯ λͺ¨λ“œ ν† κΈ€ */} +
+ + μˆ˜λ™ μž…λ ₯ λͺ¨λ“œ + +
+ { + const parsed = parseFormulaToTokens(e.target.value, sourceTable); + setTokens(parsed); + onChange(e.target.value); + }} + placeholder="{total_balance} - SUM_EXT({plan_qty})" + className="h-6 text-[10px] font-mono" + /> +

+ 직접 μˆ˜μ‹ μž…λ ₯. 예: {"{"}resultField{"}"}, SUM({"{"}column{"}"}), SUM_EXT({"{"}column{"}"}) +

+
+
+
+ ); +} + // 집계 μ„€μ • μ•„μ΄ν…œ (둜컬 μƒνƒœ κ΄€λ¦¬λ‘œ μž…λ ₯ μ‹œ λ¦¬λ Œλ”λ§ λ°©μ§€) +// πŸ†• v3.2: 닀쀑 ν…Œμ΄λΈ” 및 가상 집계(formula) 지원 function AggregationConfigItem({ agg, index, sourceTable, + allTables, + existingAggregations, onUpdate, onRemove, }: { agg: AggregationConfig; index: number; sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + existingAggregations: AggregationConfig[]; // μ—°μ‚°μ‹μ—μ„œ μ°Έμ‘°ν•  수 μžˆλŠ” κΈ°μ‘΄ 집계듀 onUpdate: (updates: Partial) => void; onRemove: () => void; }) { const [localLabel, setLocalLabel] = useState(agg.label || ""); const [localResultField, setLocalResultField] = useState(agg.resultField || ""); + const [localFormula, setLocalFormula] = useState(agg.formula || ""); // agg λ³€κ²½ μ‹œ 둜컬 μƒνƒœ 동기화 useEffect(() => { setLocalLabel(agg.label || ""); setLocalResultField(agg.resultField || ""); - }, [agg.label, agg.resultField]); + setLocalFormula(agg.formula || ""); + }, [agg.label, agg.resultField, agg.formula]); + + // ν˜„μž¬ 집계보닀 μ•žμ— μ •μ˜λœ μ§‘κ³„λ“€λ§Œ μ°Έμ‘° κ°€λŠ₯ (μˆœν™˜ μ°Έμ‘° λ°©μ§€) + const referenceableAggregations = existingAggregations.slice(0, index); + + // sourceType κΈ°λ³Έκ°’ 처리 + const currentSourceType = agg.sourceType || "column"; return ( -
+
- - 집계 {index + 1} - +
+ + {currentSourceType === "formula" ? "가상" : "집계"} {index + 1} + +
-
- - onUpdate({ sourceField: value })} - placeholder="합계할 ν•„λ“œ" - /> + {/* 집계 νƒ€μž… 선택 */} +
+ +
-
-
- - -
+ {/* === 컬럼 집계 μ„€μ • === */} + {currentSourceType === "column" && ( + <> + {/* ν…Œμ΄λΈ” 선택 */} +
+ + +

+ κΈ°λ³Έ ν…Œμ΄λΈ” μ™Έ λ‹€λ₯Έ ν…Œμ΄λΈ”λ„ 선택 κ°€λŠ₯ +

+
+ {/* 컬럼 선택 */} +
+ + onUpdate({ sourceField: value })} + placeholder="합계할 ν•„λ“œ" + /> +
+ + {/* 집계 ν•¨μˆ˜ */} +
+ + +
+ + )} + + {/* === 가상 집계 (연산식) μ„€μ • === */} + {currentSourceType === "formula" && ( + { + setLocalFormula(newFormula); + onUpdate({ formula: newFormula }); + }} + /> + )} + + {/* 곡톡: 라벨 및 κ²°κ³Ό ν•„λ“œλͺ… */} +
-
-
- - setLocalResultField(e.target.value)} - onBlur={() => onUpdate({ resultField: localResultField })} - onKeyDown={(e) => { - if (e.key === "Enter") { - onUpdate({ resultField: localResultField }); - } - }} - placeholder="total_balance_qty" - className="h-6 text-[10px]" - /> +
+ + setLocalResultField(e.target.value)} + onBlur={() => onUpdate({ resultField: localResultField })} + onKeyDown={(e) => { + if (e.key === "Enter") { + onUpdate({ resultField: localResultField }); + } + }} + placeholder="total_balance_qty" + className="h-6 text-[10px] font-mono" + /> +
); } // ν…Œμ΄λΈ” 선택기 (Combobox) - 240px μ΅œμ ν™” -function TableSelector({ value, onChange }: { value: string; onChange: (value: string) => void }) { +function TableSelector({ + value, + onChange, + allTables, + placeholder = "ν…Œμ΄λΈ” 선택", +}: { + value: string; + onChange: (value: string) => void; + allTables?: { tableName: string; displayName?: string }[]; + placeholder?: string; +}) { const [tables, setTables] = useState<{ tableName: string; displayName?: string }[]>([]); const [isLoading, setIsLoading] = useState(false); const [open, setOpen] = useState(false); useEffect(() => { + // allTablesκ°€ μ „λ‹¬λ˜λ©΄ API 호좜 없이 μ‚¬μš© + if (allTables && allTables.length > 0) { + setTables(allTables); + return; + } + const loadTables = async () => { setIsLoading(true); try { const response = await tableManagementApi.getTableList(); if (response.success && response.data) { // API 응닡이 배열인 κ²½μš°μ™€ 객체인 경우 λͺ¨λ‘ 처리 - const tableData = Array.isArray(response.data) - ? response.data + const tableData = Array.isArray(response.data) + ? response.data : (response.data as any).tables || response.data || []; setTables(tableData); } @@ -446,10 +968,10 @@ function TableSelector({ value, onChange }: { value: string; onChange: (value: s } }; loadTables(); - }, []); + }, [allTables]); const selectedTable = (tables || []).find((t) => t.tableName === value); - const displayText = selectedTable ? selectedTable.tableName : "ν…Œμ΄λΈ” 선택"; + const displayText = selectedTable ? selectedTable.tableName : placeholder; return ( @@ -587,10 +1109,19 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM }); }; - const addAggregation = () => { + const addAggregation = (sourceType: "column" | "formula" = "column") => { const newAgg: AggregationConfig = { - sourceField: "", - type: "sum", + sourceType, + // column νƒ€μž… κΈ°λ³Έκ°’ + ...(sourceType === "column" && { + sourceTable: localConfig.dataSource?.sourceTable || "", + sourceField: "", + type: "sum" as const, + }), + // formula νƒ€μž… κΈ°λ³Έκ°’ + ...(sourceType === "formula" && { + formula: "", + }), resultField: `agg_${Date.now()}`, label: "", }; @@ -766,6 +1297,7 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM const addContentRowAggField = (rowIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; const newAggField: AggregationDisplayConfig = { + sourceType: "aggregation", aggregationResultField: "", label: "", }; @@ -790,6 +1322,20 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM updateConfig({ contentRows: newRows }); }; + // πŸ†• 집계 ν•„λ“œ μˆœμ„œ λ³€κ²½ + const moveContentRowAggField = (rowIndex: number, fieldIndex: number, direction: "up" | "down") => { + const newRows = [...(localConfig.contentRows || [])]; + const fields = newRows[rowIndex].aggregationFields; + if (!fields) return; + + const newIndex = direction === "up" ? fieldIndex - 1 : fieldIndex + 1; + if (newIndex < 0 || newIndex >= fields.length) return; + + // λ°°μ—΄ μš”μ†Œ κ΅ν™˜ + [fields[fieldIndex], fields[newIndex]] = [fields[newIndex], fields[fieldIndex]]; + updateConfig({ contentRows: newRows }); + }; + // contentRow λ‚΄ ν…Œμ΄λΈ” 컬럼 관리 (table νƒ€μž…) const addContentRowTableColumn = (rowIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; @@ -818,6 +1364,23 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM updateConfig({ contentRows: newRows }); }; + // ν…Œμ΄λΈ” 컬럼 μˆœμ„œ λ³€κ²½ + const moveContentRowTableColumn = (rowIndex: number, colIndex: number, direction: "up" | "down") => { + const newRows = [...(localConfig.contentRows || [])]; + const columns = newRows[rowIndex].tableColumns; + if (!columns) return; + + const newIndex = direction === "up" ? colIndex - 1 : colIndex + 1; + if (newIndex < 0 || newIndex >= columns.length) return; + + // 컬럼 μœ„μΉ˜ κ΅ν™˜ + const newColumns = [...columns]; + [newColumns[colIndex], newColumns[newIndex]] = [newColumns[newIndex], newColumns[colIndex]]; + newRows[rowIndex].tableColumns = newColumns; + + updateConfig({ contentRows: newRows }); + }; + // === (λ ˆκ±°μ‹œ) Simple λͺ¨λ“œ ν–‰/컬럼 κ΄€λ ¨ ν•¨μˆ˜ === const addRow = () => { const newRow: CardRowConfig = { @@ -874,10 +1437,10 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM }; return ( - -
+
+
- + κΈ°λ³Έ @@ -1014,8 +1577,8 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM {/* === κ·Έλ£Ήν•‘ μ„€μ • νƒ­ === */} - -
+ +

κ·Έλ£Ήν•‘

{/* 집계 μ„€μ • */} -
+
- +
+ + +
+

+ 컬럼 집계: ν…Œμ΄λΈ” 컬럼의 합계/개수 λ“± | 가상 집계: μ—°μ‚°μ‹μœΌλ‘œ 계산 +

+ {(localConfig.grouping?.aggregations || []).map((agg, index) => ( updateAggregation(index, updates)} onRemove={() => removeAggregation(index)} /> @@ -1139,9 +1726,11 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM onAddAggField={() => addContentRowAggField(rowIndex)} onRemoveAggField={(fieldIndex) => removeContentRowAggField(rowIndex, fieldIndex)} onUpdateAggField={(fieldIndex, updates) => updateContentRowAggField(rowIndex, fieldIndex, updates)} + onMoveAggField={(fieldIndex, direction) => moveContentRowAggField(rowIndex, fieldIndex, direction)} onAddTableColumn={() => addContentRowTableColumn(rowIndex)} onRemoveTableColumn={(colIndex) => removeContentRowTableColumn(rowIndex, colIndex)} onUpdateTableColumn={(colIndex, updates) => updateContentRowTableColumn(rowIndex, colIndex, updates)} + onMoveTableColumn={(colIndex, direction) => moveContentRowTableColumn(rowIndex, colIndex, direction)} /> ))}
@@ -1155,9 +1744,10 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM )}
+
- +
); } @@ -1177,9 +1767,11 @@ function ContentRowConfigSection({ onAddAggField, onRemoveAggField, onUpdateAggField, + onMoveAggField, onAddTableColumn, onRemoveTableColumn, onUpdateTableColumn, + onMoveTableColumn, }: { row: CardContentRowConfig; rowIndex: number; @@ -1195,9 +1787,11 @@ function ContentRowConfigSection({ onAddAggField: () => void; onRemoveAggField: (fieldIndex: number) => void; onUpdateAggField: (fieldIndex: number, updates: Partial) => void; + onMoveAggField: (fieldIndex: number, direction: "up" | "down") => void; onAddTableColumn: () => void; onRemoveTableColumn: (colIndex: number) => void; onUpdateTableColumn: (colIndex: number, updates: Partial) => void; + onMoveTableColumn?: (colIndex: number, direction: "up" | "down") => void; }) { // 둜컬 μƒνƒœλ‘œ Input ν•„λ“œ 관리 (타이핑 μ‹œ λ¦¬λ Œλ”λ§ λ°©μ§€) const [localTableTitle, setLocalTableTitle] = useState(row.tableTitle || ""); @@ -1397,9 +1991,34 @@ function ContentRowConfigSection({ {(row.aggregationFields || []).map((field, fieldIndex) => (
- - 집계 {fieldIndex + 1} - +
+ {/* μˆœμ„œ λ³€κ²½ λ²„νŠΌ */} +
+ + +
+ + 집계 {fieldIndex + 1} + +
@@ -1507,6 +2126,502 @@ function ContentRowConfigSection({
+ {/* μ™ΈλΆ€ ν…Œμ΄λΈ” 데이터 μ†ŒμŠ€ μ„€μ • */} +
+
+ + onUpdateRow({ + tableDataSource: checked + ? { enabled: true, sourceTable: "", joinConditions: [] } + : undefined, + }) + } + className="scale-[0.6]" + /> + +
+ + {row.tableDataSource?.enabled && ( +
+
+ + +
+ +
+ +
+
+ μ™ΈλΆ€ ν…Œμ΄λΈ” ν‚€ + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + joinConditions: [ + { + ...row.tableDataSource?.joinConditions?.[0], + sourceKey: value, + referenceKey: row.tableDataSource?.joinConditions?.[0]?.referenceKey || "", + }, + ], + }, + }) + } + placeholder="ν‚€ 선택" + /> +
+
+ μΉ΄λ“œ 데이터 ν‚€ + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + joinConditions: [ + { + ...row.tableDataSource?.joinConditions?.[0], + sourceKey: row.tableDataSource?.joinConditions?.[0]?.sourceKey || "", + referenceKey: value, + }, + ], + }, + }) + } + placeholder="ν‚€ 선택" + /> +
+
+
+ +
+
+ + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + orderBy: value ? { column: value, direction: row.tableDataSource?.orderBy?.direction || "desc" } : undefined, + }, + }) + } + placeholder="선택" + /> +
+
+ + +
+
+ + {/* πŸ†• μΆ”κ°€ 쑰인 ν…Œμ΄λΈ” μ„€μ • */} +
+
+ + +
+

+ μ†ŒμŠ€ ν…Œμ΄λΈ”μ— μ—†λŠ” μ»¬λŸΌμ„ λ‹€λ₯Έ ν…Œμ΄λΈ”μ—μ„œ μ‘°μΈν•˜μ—¬ κ°€μ Έμ˜΅λ‹ˆλ‹€ +

+ + {(row.tableDataSource?.additionalJoins || []).map((join, joinIndex) => ( +
+
+ + 쑰인 {joinIndex + 1} + + +
+ + {/* 쑰인 ν…Œμ΄λΈ” 선택 */} +
+ + { + const newJoins = [...(row.tableDataSource?.additionalJoins || [])]; + newJoins[joinIndex] = { ...join, joinTable: value }; + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + additionalJoins: newJoins, + }, + }); + }} + placeholder="ν…Œμ΄λΈ” 선택" + /> +
+ + {/* 쑰인 쑰건 */} + {join.joinTable && ( +
+ +
+
+ { + const newJoins = [...(row.tableDataSource?.additionalJoins || [])]; + newJoins[joinIndex] = { ...join, sourceKey: value }; + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + additionalJoins: newJoins, + }, + }); + }} + placeholder="μ†ŒμŠ€ ν‚€" + /> +
+ = +
+ { + const newJoins = [...(row.tableDataSource?.additionalJoins || [])]; + newJoins[joinIndex] = { ...join, targetKey: value }; + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + additionalJoins: newJoins, + }, + }); + }} + placeholder="쑰인 ν‚€" + /> +
+
+

+ {row.tableDataSource?.sourceTable}.{join.sourceKey || "?"} = {join.joinTable}.{join.targetKey || "?"} +

+
+ )} +
+ ))} + + {(row.tableDataSource?.additionalJoins || []).length === 0 && ( +
+ 쑰인 ν…Œμ΄λΈ” μ—†μŒ (μ†ŒμŠ€ ν…Œμ΄λΈ” 컬럼만 μ‚¬μš©) +
+ )} +
+ + {/* πŸ†• v3.4: ν•„ν„° μ„€μ • */} +
+
+ + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: checked + ? { + enabled: true, + filterField: "", + filterType: "equals", + referenceField: "", + referenceSource: "representativeData", + } + : undefined, + }, + }) + } + className="scale-[0.6]" + /> +
+

+ κ·Έλ£Ή λ‚΄ 데이터λ₯Ό νŠΉμ • 쑰건으둜 ν•„ν„°λ§ν•©λ‹ˆλ‹€ (같은 κ°’λ§Œ / λ‹€λ₯Έ κ°’λ§Œ) +

+ + {row.tableDataSource?.filterConfig?.enabled && ( +
+ {/* ν•„ν„° νƒ€μž… */} +
+ + +
+ + {/* ν•„ν„° ν•„λ“œ */} +
+ + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: { + ...row.tableDataSource!.filterConfig!, + filterField: value, + }, + }, + }) + } + placeholder="필터링할 컬럼 선택" + /> +

+ 이 컬럼 값을 κΈ°μ€€μœΌλ‘œ ν•„ν„°λ§ν•©λ‹ˆλ‹€ +

+
+ + {/* 비ꡐ κΈ°μ€€ μ†ŒμŠ€ */} +
+ + +
+ + {/* 비ꡐ κΈ°μ€€ ν•„λ“œ */} +
+ + {row.tableDataSource.filterConfig.referenceSource === "representativeData" ? ( + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: { + ...row.tableDataSource!.filterConfig!, + referenceField: value, + }, + }, + }) + } + placeholder="비ꡐ할 ν•„λ“œ 선택" + /> + ) : ( + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: { + ...row.tableDataSource!.filterConfig!, + referenceField: e.target.value, + }, + }, + }) + } + placeholder="formData ν•„λ“œλͺ… (예: selectedOrderNo)" + className="h-6 text-[10px]" + /> + )} +

+ 이 κ°’κ³Ό λΉ„κ΅ν•˜μ—¬ ν•„ν„°λ§ν•©λ‹ˆλ‹€ +

+
+ + {/* ν•„ν„° 쑰건 미리보기 */} + {row.tableDataSource.filterConfig.filterField && row.tableDataSource.filterConfig.referenceField && ( +
+ 쑰건: + {row.tableDataSource.sourceTable}.{row.tableDataSource.filterConfig.filterField} + {row.tableDataSource.filterConfig.filterType === "equals" ? " = " : " != "} + {row.tableDataSource.filterConfig.referenceSource === "representativeData" + ? `μΉ΄λ“œ.${row.tableDataSource.filterConfig.referenceField}` + : `formData.${row.tableDataSource.filterConfig.referenceField}` + } +
+ )} +
+ )} +
+
+ )} +
+ + {/* CRUD μ„€μ • */} +
+ +
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false }, + }) + } + className="scale-[0.5]" + /> + +
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false }, + }) + } + className="scale-[0.5]" + /> + +
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked }, + }) + } + className="scale-[0.5]" + /> + +
+
+ {row.tableCrud?.allowDelete && ( +
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud!, deleteConfirm: { enabled: checked } }, + }) + } + className="scale-[0.5]" + /> + +
+ )} +
+ {/* ν…Œμ΄λΈ” 컬럼 λͺ©λ‘ */}
@@ -1523,9 +2638,14 @@ function ContentRowConfigSection({ col={col} colIndex={colIndex} allTables={allTables} - dataSourceTable={dataSourceTable} + dataSourceTable={row.tableDataSource?.enabled ? row.tableDataSource.sourceTable : dataSourceTable} + additionalJoins={row.tableDataSource?.additionalJoins || []} onUpdate={(updates) => onUpdateTableColumn(colIndex, updates)} onRemove={() => onRemoveTableColumn(colIndex)} + onMoveUp={() => onMoveTableColumn?.(colIndex, "up")} + onMoveDown={() => onMoveTableColumn?.(colIndex, "down")} + isFirst={colIndex === 0} + isLast={colIndex === (row.tableColumns || []).length - 1} /> ))} @@ -1945,20 +3065,42 @@ function TableColumnConfigSection({ colIndex, allTables, dataSourceTable, + additionalJoins, onUpdate, onRemove, + onMoveUp, + onMoveDown, + isFirst, + isLast, }: { col: TableColumnConfig; colIndex: number; allTables: { tableName: string; displayName?: string }[]; dataSourceTable: string; + additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[]; onUpdate: (updates: Partial) => void; onRemove: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + isFirst?: boolean; + isLast?: boolean; }) { // 둜컬 μƒνƒœλ‘œ Input ν•„λ“œ 관리 (타이핑 μ‹œ λ¦¬λ Œλ”λ§ λ°©μ§€) const [localLabel, setLocalLabel] = useState(col.label || ""); const [localWidth, setLocalWidth] = useState(col.width || ""); + // μ„ νƒλœ ν…Œμ΄λΈ” (μ†ŒμŠ€ ν…Œμ΄λΈ” λ˜λŠ” 쑰인 ν…Œμ΄λΈ”) + const selectedTable = col.fromTable || dataSourceTable; + const selectedJoinId = col.fromJoinId || ""; + + // μ‚¬μš© κ°€λŠ₯ν•œ ν…Œμ΄λΈ” λͺ©λ‘ (μ†ŒμŠ€ ν…Œμ΄λΈ” + 쑰인 ν…Œμ΄λΈ”λ“€) + const availableTables = [ + { id: "", table: dataSourceTable, label: `${dataSourceTable} (μ†ŒμŠ€)` }, + ...additionalJoins + .filter(j => j.joinTable) + .map(j => ({ id: j.id, table: j.joinTable, label: `${j.joinTable} (쑰인)` })), + ]; + useEffect(() => { setLocalLabel(col.label || ""); setLocalWidth(col.width || ""); @@ -1979,19 +3121,80 @@ function TableColumnConfigSection({ return (
- - 컬럼 {colIndex + 1} - +
+ {/* μˆœμ„œ λ³€κ²½ λ²„νŠΌ */} +
+ + +
+ + 컬럼 {colIndex + 1} + + {col.fromJoinId && ( + + 쑰인 + + )} +
+ {/* ν…Œμ΄λΈ” 선택 (쑰인 ν…Œμ΄λΈ”μ΄ μžˆμ„ λ•Œλ§Œ ν‘œμ‹œ) */} + {additionalJoins.length > 0 && ( +
+ + +
+ )} +
onUpdate({ field: value })} placeholder="ν•„λ“œ 선택" diff --git a/frontend/lib/registry/components/repeat-screen-modal/types.ts b/frontend/lib/registry/components/repeat-screen-modal/types.ts index b2c8d861..81a36366 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/types.ts +++ b/frontend/lib/registry/components/repeat-screen-modal/types.ts @@ -23,6 +23,9 @@ export interface RepeatScreenModalProps { // === πŸ†• v3: 자유 λ ˆμ΄μ•„μ›ƒ === contentRows?: CardContentRowConfig[]; // μΉ΄λ“œ λ‚΄λΆ€ ν–‰λ“€ (각 ν–‰λ§ˆλ‹€ νƒ€μž… 선택) + // === πŸ†• v3.1: Footer λ²„νŠΌ μ„€μ • === + footerConfig?: FooterConfig; // Footer μ˜μ—­ μ„€μ • + // === (λ ˆκ±°μ‹œ ν˜Έν™˜) === cardMode?: "simple" | "withTable"; // @deprecated - contentRows μ‚¬μš© ꢌμž₯ cardLayout?: CardRowConfig[]; // @deprecated - contentRows μ‚¬μš© ꢌμž₯ @@ -33,6 +36,34 @@ export interface RepeatScreenModalProps { onChange?: (newData: any[]) => void; } +/** + * πŸ†• v3.1: Footer μ„€μ • + */ +export interface FooterConfig { + enabled: boolean; // Footer μ‚¬μš© μ—¬λΆ€ + buttons?: FooterButtonConfig[]; // Footer λ²„νŠΌλ“€ + position?: "sticky" | "static"; // sticky: ν•˜λ‹¨ κ³ μ •, static: 컨텐츠 μ•„λž˜ + alignment?: "left" | "center" | "right"; // λ²„νŠΌ μ •λ ¬ +} + +/** + * πŸ†• v3.1: Footer λ²„νŠΌ μ„€μ • + */ +export interface FooterButtonConfig { + id: string; // λ²„νŠΌ 고유 ID + label: string; // λ²„νŠΌ 라벨 + action: "save" | "cancel" | "close" | "reset" | "custom"; // μ•‘μ…˜ νƒ€μž… + variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; // λ²„νŠΌ μŠ€νƒ€μΌ + icon?: string; // μ•„μ΄μ½˜ (lucide μ•„μ΄μ½˜λͺ…) + disabled?: boolean; // λΉ„ν™œμ„±ν™” μ—¬λΆ€ + + // custom μ•‘μ…˜μΌ λ•Œ + customAction?: { + type: string; // μ»€μŠ€ν…€ μ•‘μ…˜ νƒ€μž… + config?: Record; // μ»€μŠ€ν…€ μ„€μ • + }; +} + /** * 데이터 μ†ŒμŠ€ μ„€μ • */ @@ -79,26 +110,177 @@ export interface CardContentRowConfig { tableTitle?: string; // ν…Œμ΄λΈ” 제λͺ© showTableHeader?: boolean; // ν…Œμ΄λΈ” 헀더 ν‘œμ‹œ μ—¬λΆ€ tableMaxHeight?: string; // ν…Œμ΄λΈ” μ΅œλŒ€ 높이 + + // πŸ†• v3.1: ν…Œμ΄λΈ” μ™ΈλΆ€ 데이터 μ†ŒμŠ€ + tableDataSource?: TableDataSourceConfig; // μ™ΈλΆ€ ν…Œμ΄λΈ”μ—μ„œ 데이터 쑰회 + + // πŸ†• v3.1: ν…Œμ΄λΈ” CRUD μ„€μ • + tableCrud?: TableCrudConfig; // ν–‰ μΆ”κ°€/μˆ˜μ •/μ‚­μ œ μ„€μ • +} + +/** + * πŸ†• v3.1: ν…Œμ΄λΈ” 데이터 μ†ŒμŠ€ μ„€μ • + * μ™ΈλΆ€ ν…Œμ΄λΈ”μ—μ„œ μ—°κ΄€ 데이터λ₯Ό 쑰회 + */ +export interface TableDataSourceConfig { + enabled: boolean; // μ™ΈλΆ€ 데이터 μ†ŒμŠ€ μ‚¬μš© μ—¬λΆ€ + sourceTable: string; // μ‘°νšŒν•  ν…Œμ΄λΈ” (예: "shipment_plan") + + // 쑰인 μ„€μ • + joinConditions: JoinCondition[]; // 쑰인 쑰건 (볡합 ν‚€ 지원) + + // πŸ†• v3.3: μΆ”κ°€ 쑰인 ν…Œμ΄λΈ” μ„€μ • (μ†ŒμŠ€ ν…Œμ΄λΈ”μ— μ—†λŠ” 컬럼 쑰회) + additionalJoins?: AdditionalJoinConfig[]; + + // πŸ†• v3.4: ν•„ν„° 쑰건 μ„€μ • (κ·Έλ£Ή λ‚΄ νŠΉμ • 쑰건으둜 필터링) + filterConfig?: TableFilterConfig; + + // μ •λ ¬ μ„€μ • + orderBy?: { + column: string; // μ •λ ¬ 컬럼 + direction: "asc" | "desc"; // μ •λ ¬ λ°©ν–₯ + }; + + // μ œν•œ + limit?: number; // μ΅œλŒ€ ν–‰ 수 +} + +/** + * πŸ†• v3.4: ν…Œμ΄λΈ” ν•„ν„° μ„€μ • + * κ·Έλ£Ή λ‚΄ 데이터λ₯Ό νŠΉμ • 쑰건으둜 필터링 + */ +export interface TableFilterConfig { + enabled: boolean; // ν•„ν„° μ‚¬μš© μ—¬λΆ€ + filterField: string; // 필터링할 ν•„λ“œ (예: "order_no") + filterType: "equals" | "notEquals"; // equals: 같은 κ°’λ§Œ, notEquals: λ‹€λ₯Έ κ°’λ§Œ + referenceField: string; // 비ꡐ κΈ°μ€€ ν•„λ“œ (formData λ˜λŠ” μΉ΄λ“œ λŒ€ν‘œ λ°μ΄ν„°μ—μ„œ) + referenceSource: "formData" | "representativeData"; // 비ꡐ κ°’ μ†ŒμŠ€ +} + +/** + * πŸ†• v3.3: μΆ”κ°€ 쑰인 ν…Œμ΄λΈ” μ„€μ • + * μ†ŒμŠ€ ν…Œμ΄λΈ”μ—μ„œ λ‹€λ₯Έ ν…Œμ΄λΈ”μ„ μ‘°μΈν•˜μ—¬ 컬럼 κ°€μ Έμ˜€κΈ° + */ +export interface AdditionalJoinConfig { + id: string; // 쑰인 μ„€μ • 고유 ID + joinTable: string; // 쑰인할 ν…Œμ΄λΈ” (예: "sales_order_mng") + joinType: "left" | "inner"; // 쑰인 νƒ€μž… + sourceKey: string; // μ†ŒμŠ€ ν…Œμ΄λΈ”μ˜ 쑰인 ν‚€ (예: "sales_order_id") + targetKey: string; // 쑰인 ν…Œμ΄λΈ”μ˜ ν‚€ (예: "id") + alias?: string; // ν…Œμ΄λΈ” 별칭 (예: "so") + selectColumns?: string[]; // κ°€μ Έμ˜¬ 컬럼 λͺ©λ‘ (λΉ„μ–΄μžˆμœΌλ©΄ 전체) +} + +/** + * πŸ†• v3.1: 쑰인 쑰건 + */ +export interface JoinCondition { + sourceKey: string; // μ™ΈλΆ€ ν…Œμ΄λΈ”μ˜ 쑰인 ν‚€ (예: "sales_order_id") + referenceKey: string; // ν˜„μž¬ μΉ΄λ“œ λ°μ΄ν„°μ˜ μ°Έμ‘° ν‚€ (예: "id") + referenceType?: "card" | "row"; // card: μΉ΄λ“œ λŒ€ν‘œ 데이터, row: 각 ν–‰ 데이터 (κΈ°λ³Έ: card) +} + +/** + * πŸ†• v3.1: ν…Œμ΄λΈ” CRUD μ„€μ • + */ +export interface TableCrudConfig { + allowCreate: boolean; // ν–‰ μΆ”κ°€ ν—ˆμš© + allowUpdate: boolean; // ν–‰ μˆ˜μ • ν—ˆμš© + allowDelete: boolean; // ν–‰ μ‚­μ œ ν—ˆμš© + + // μ‹ κ·œ ν–‰ κΈ°λ³Έκ°’ + newRowDefaults?: Record; // κΈ°λ³Έκ°’ (예: { status: "READY", sales_order_id: "{id}" }) + + // μ‚­μ œ 확인 + deleteConfirm?: { + enabled: boolean; // μ‚­μ œ 확인 νŒμ—… ν‘œμ‹œ μ—¬λΆ€ + message?: string; // 확인 λ©”μ‹œμ§€ + }; + + // μ €μž₯ λŒ€μƒ ν…Œμ΄λΈ” (μ™ΈλΆ€ 데이터 μ†ŒμŠ€ μ‚¬μš© μ‹œ) + targetTable?: string; // μ €μž₯ν•  ν…Œμ΄λΈ” (κΈ°λ³Έ: tableDataSource.sourceTable) } /** * πŸ†• v3: 집계 ν‘œμ‹œ μ„€μ • */ export interface AggregationDisplayConfig { - aggregationResultField: string; // κ·Έλ£Ήν•‘ μ„€μ •μ˜ resultField μ°Έμ‘° + // κ°’ μ†ŒμŠ€ νƒ€μž… + sourceType: "aggregation" | "formula" | "external" | "externalFormula"; + + // === sourceType: "aggregation" (κΈ°μ‘΄ κ·Έλ£Ήν•‘ 집계 κ²°κ³Ό μ°Έμ‘°) === + aggregationResultField?: string; // κ·Έλ£Ήν•‘ μ„€μ •μ˜ resultField μ°Έμ‘° + + // === sourceType: "formula" (컬럼 κ°„ μ—°μ‚°) === + formula?: string; // 연산식 (예: "{order_qty} - {ship_qty}") + + // === sourceType: "external" (μ™ΈλΆ€ ν…Œμ΄λΈ” 쑰회) === + externalSource?: ExternalValueSource; + + // === sourceType: "externalFormula" (μ™ΈλΆ€ ν…Œμ΄λΈ” + μ—°μ‚°) === + externalSources?: ExternalValueSource[]; // μ—¬λŸ¬ μ™ΈλΆ€ μ†ŒμŠ€ + externalFormula?: string; // μ™ΈλΆ€ 값듀을 μ‘°ν•©ν•œ 연산식 (예: "{inv_qty} + {prod_qty}") + + // ν‘œμ‹œ μ„€μ • label: string; // ν‘œμ‹œ 라벨 icon?: string; // μ•„μ΄μ½˜ (lucide μ•„μ΄μ½˜λͺ…) backgroundColor?: string; // 배경색 textColor?: string; // ν…μŠ€νŠΈ 색상 fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기 + format?: "number" | "currency" | "percent"; // 숫자 포맷 + decimalPlaces?: number; // μ†Œμˆ˜μ  자릿수 +} + +/** + * πŸ†• v3.1: μ™ΈλΆ€ κ°’ μ†ŒμŠ€ μ„€μ • + */ +export interface ExternalValueSource { + alias: string; // μ—°μ‚°μ‹μ—μ„œ μ‚¬μš©ν•  별칭 (예: "inv_qty") + sourceTable: string; // μ‘°νšŒν•  ν…Œμ΄λΈ” + sourceColumn: string; // μ‘°νšŒν•  컬럼 + aggregationType?: "sum" | "count" | "avg" | "min" | "max" | "first"; // 집계 νƒ€μž… (κΈ°λ³Έ: first) + + // 쑰인 μ„€μ • (닀단계 쑰인 지원) + joins: ChainedJoinConfig[]; +} + +/** + * πŸ†• v3.1: 닀단계 쑰인 μ„€μ • + */ +export interface ChainedJoinConfig { + step: number; // 쑰인 μˆœμ„œ (1, 2, 3...) + sourceTable: string; // 쑰인할 ν…Œμ΄λΈ” + joinConditions: { + sourceKey: string; // 쑰인 ν…Œμ΄λΈ”μ˜ ν‚€ + referenceKey: string; // μ°Έμ‘° ν‚€ (이전 단계 κ²°κ³Ό λ˜λŠ” μΉ΄λ“œ 데이터) + referenceFrom?: "card" | "previousStep"; // μ°Έμ‘° μ†ŒμŠ€ (κΈ°λ³Έ: card, step > 1이면 previousStep) + }[]; + selectColumns?: string[]; // 이 λ‹¨κ³„μ—μ„œ 선택할 컬럼 } /** * 집계 μ„€μ • + * πŸ†• v3.2: 닀쀑 ν…Œμ΄λΈ” 및 가상 집계(formula) 지원 */ export interface AggregationConfig { - sourceField: string; // 원본 ν•„λ“œ (예: "balance_qty") - type: "sum" | "count" | "avg" | "min" | "max"; // 집계 νƒ€μž… + // === 집계 μ†ŒμŠ€ νƒ€μž… === + sourceType: "column" | "formula"; // column: ν…Œμ΄λΈ” 컬럼 집계, formula: 연산식 (가상 집계) + + // === sourceType: "column" (ν…Œμ΄λΈ” 컬럼 집계) === + sourceTable?: string; // 집계할 ν…Œμ΄λΈ” (κΈ°λ³Έ: dataSource.sourceTable, μ™ΈλΆ€ ν…Œμ΄λΈ”λ„ κ°€λŠ₯) + sourceField?: string; // 원본 ν•„λ“œ (예: "balance_qty") + type?: "sum" | "count" | "avg" | "min" | "max"; // 집계 νƒ€μž… + + // === sourceType: "formula" (가상 집계 - 연산식) === + // 연산식 문법: + // - {resultField}: λ‹€λ₯Έ 집계 κ²°κ³Ό μ°Έμ‘° (예: {total_balance}) + // - {ν…Œμ΄λΈ”.컬럼}: ν…Œμ΄λΈ”μ˜ 컬럼 직접 μ°Έμ‘° (예: {sales_order_mng.order_qty}) + // - SUM({컬럼}): κΈ°λ³Έ ν…Œμ΄λΈ” ν–‰λ“€μ˜ 합계 + // - SUM_EXT({컬럼}): μ™ΈλΆ€ ν…Œμ΄λΈ” ν–‰λ“€μ˜ 합계 (externalTableData) + // - μ‚°μˆ  μ—°μ‚°: +, -, *, /, () + formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})") + + // === 곡톡 === resultField: string; // κ²°κ³Ό ν•„λ“œλͺ… (예: "total_balance_qty") label: string; // ν‘œμ‹œ 라벨 (예: "μ΄μˆ˜μ£Όμž”λŸ‰") } @@ -120,7 +302,7 @@ export interface TableLayoutConfig { */ export interface TableColumnConfig { id: string; // 컬럼 고유 ID - field: string; // ν•„λ“œλͺ… + field: string; // ν•„λ“œλͺ… (μ†ŒμŠ€ ν…Œμ΄λΈ” 컬럼 λ˜λŠ” 쑰인 ν…Œμ΄λΈ” 컬럼) label: string; // 헀더 라벨 type: "text" | "number" | "date" | "select" | "badge"; // νƒ€μž… width?: string; // λ„ˆλΉ„ (예: "100px", "20%") @@ -128,6 +310,10 @@ export interface TableColumnConfig { editable: boolean; // νŽΈμ§‘ κ°€λŠ₯ μ—¬λΆ€ required?: boolean; // ν•„μˆ˜ μž…λ ₯ μ—¬λΆ€ + // πŸ†• v3.3: 컬럼 μ†ŒμŠ€ ν…Œμ΄λΈ” μ§€μ • (쑰인 ν…Œμ΄λΈ” 컬럼 μ‚¬μš© μ‹œ) + fromTable?: string; // 컬럼이 μ†ν•œ ν…Œμ΄λΈ” (λΉ„μ–΄μžˆμœΌλ©΄ μ†ŒμŠ€ ν…Œμ΄λΈ”) + fromJoinId?: string; // additionalJoins의 id μ°Έμ‘° (쑰인 ν…Œμ΄λΈ” 컬럼일 λ•Œ) + // Select νƒ€μž… μ˜΅μ…˜ selectOptions?: { value: string; label: string }[]; diff --git a/frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx b/frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx index c4e653e1..fb9dc6fe 100644 --- a/frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx +++ b/frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx @@ -25,3 +25,4 @@ if (process.env.NODE_ENV === "development") { SectionPaperRenderer.enableHotReload(); } + diff --git a/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts b/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts index 1f6f69f2..8a0fdba5 100644 --- a/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts +++ b/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts @@ -65,3 +65,4 @@ export function useCalculation(calculationRules: CalculationRule[] = []) { }; } +