diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 5272547a..7ba5c47e 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -607,7 +607,9 @@ class NumberingRuleService { } const result = await pool.query(query, params); - if (result.rowCount === 0) return null; + if (result.rowCount === 0) { + return null; + } const rule = result.rows[0]; diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index f4a3ccf7..967e43ca 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -317,6 +317,11 @@ apiClient.interceptors.response.use( return Promise.reject(error); } + // 채번 규칙 미리보기 API 실패는 조용하게 처리 (화면 로드 시 자주 발생) + if (url?.includes("/numbering-rules/") && url?.includes("/preview")) { + return Promise.reject(error); + } + // 다른 에러들은 기존처럼 상세 로그 출력 console.error("API 응답 오류:", { status: status, @@ -324,7 +329,6 @@ apiClient.interceptors.response.use( url: url, data: error.response?.data, message: error.message, - headers: error.config?.headers, }); // 401 에러 처리 diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts index b531edce..551a8d25 100644 --- a/frontend/lib/api/numberingRule.ts +++ b/frontend/lib/api/numberingRule.ts @@ -109,11 +109,24 @@ export async function deleteNumberingRule(ruleId: string): Promise> { + // ruleId 유효성 검사 + if (!ruleId || ruleId === "undefined" || ruleId === "null") { + return { success: false, error: "채번 규칙 ID가 설정되지 않았습니다" }; + } + try { const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`); + if (!response.data) { + return { success: false, error: "서버 응답이 비어있습니다" }; + } return response.data; } catch (error: any) { - return { success: false, error: error.message || "코드 미리보기 실패" }; + const errorMessage = + error.response?.data?.error || + error.response?.data?.message || + error.message || + "코드 미리보기 실패"; + return { success: false, error: errorMessage }; } } diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 85e43ce9..1a8012de 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -590,18 +590,24 @@ export function RepeatScreenModalComponent({ if (!hasExternalAggregation) return; - // contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기 - const tableRowWithExternalSource = contentRows.find( + // contentRows에서 외부 테이블 데이터 소스가 있는 모든 table 타입 행 찾기 + const tableRowsWithExternalSource = contentRows.filter( (row) => row.type === "table" && row.tableDataSource?.enabled ); - if (!tableRowWithExternalSource) return; + if (tableRowsWithExternalSource.length === 0) return; // 각 카드의 집계 재계산 const updatedCards = groupedCardsData.map((card) => { - const key = `${card._cardId}-${tableRowWithExternalSource.id}`; - // 🆕 v3.7: 삭제된 행은 집계에서 제외 - const externalRows = (extData[key] || []).filter((row) => !row._isDeleted); + // 🆕 v3.11: 모든 외부 테이블 행의 데이터를 합침 + const allExternalRows: any[] = []; + for (const tableRow of tableRowsWithExternalSource) { + const key = `${card._cardId}-${tableRow.id}`; + // 🆕 v3.7: 삭제된 행은 집계에서 제외 + const rows = (extData[key] || []).filter((row) => !row._isDeleted); + allExternalRows.push(...rows); + } + const externalRows = allExternalRows; // 집계 재계산 const newAggregations: Record = {}; diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx index 44ee5ce6..1789af9e 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx @@ -26,6 +26,8 @@ import { tableManagementApi } from "@/lib/api/tableManagement"; 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; interface RepeatScreenModalConfigPanelProps { config: Partial; @@ -729,6 +731,1411 @@ function FormulaBuilder({ ); } +// 🆕 집계 설정 전용 모달 +function AggregationSettingsModal({ + open, + onOpenChange, + aggregations, + sourceTable, + allTables, + onSave, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + aggregations: AggregationConfig[]; + sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + onSave: (aggregations: AggregationConfig[]) => void; +}) { + // 로컬 상태로 집계 목록 관리 + const [localAggregations, setLocalAggregations] = useState(aggregations); + + // 모달 열릴 때 초기화 + useEffect(() => { + if (open) { + setLocalAggregations(aggregations); + } + }, [open, aggregations]); + + // 집계 추가 + const addAggregation = (type: "column" | "formula") => { + const newAgg: AggregationConfig = { + sourceType: type, + resultField: `agg_${Date.now()}`, + label: type === "column" ? "새 집계" : "새 가상 집계", + ...(type === "column" ? { type: "sum", sourceField: "", sourceTable: sourceTable } : { formula: "" }), + }; + setLocalAggregations([...localAggregations, newAgg]); + }; + + // 집계 삭제 + const removeAggregation = (index: number) => { + const newAggs = [...localAggregations]; + newAggs.splice(index, 1); + setLocalAggregations(newAggs); + }; + + // 집계 업데이트 + const updateAggregation = (index: number, updates: Partial) => { + const newAggs = [...localAggregations]; + newAggs[index] = { ...newAggs[index], ...updates }; + setLocalAggregations(newAggs); + }; + + // 집계 순서 변경 + const moveAggregation = (index: number, direction: "up" | "down") => { + const newIndex = direction === "up" ? index - 1 : index + 1; + if (newIndex < 0 || newIndex >= localAggregations.length) return; + + const newAggs = [...localAggregations]; + [newAggs[index], newAggs[newIndex]] = [newAggs[newIndex], newAggs[index]]; + setLocalAggregations(newAggs); + }; + + // 저장 + const handleSave = () => { + onSave(localAggregations); + onOpenChange(false); + }; + + return ( + + + + 집계 설정 + + 그룹 내 데이터의 합계, 개수, 평균 등을 계산합니다. 가상 집계는 다른 집계 결과를 참조하여 연산할 수 있습니다. + + + +
+ +
+ {/* 집계 추가 버튼 */} +
+ + +
+ + {/* 집계 목록 */} + {localAggregations.length === 0 ? ( +
+

집계 설정이 없습니다

+

+ 위의 버튼으로 컬럼 집계 또는 가상 집계를 추가하세요 +

+
+ ) : ( +
+ {localAggregations.map((agg, index) => ( + updateAggregation(index, updates)} + onRemove={() => removeAggregation(index)} + onMove={(direction) => moveAggregation(index, direction)} + /> + ))} +
+ )} +
+
+
+ + + + + +
+
+ ); +} + +// 집계 설정 아이템 (모달용 - 더 넓은 공간 활용) +function AggregationConfigItemModal({ + agg, + index, + totalCount, + sourceTable, + allTables, + existingAggregations, + onUpdate, + onRemove, + onMove, +}: { + agg: AggregationConfig; + index: number; + totalCount: number; + sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + existingAggregations: AggregationConfig[]; + onUpdate: (updates: Partial) => void; + onRemove: () => void; + onMove: (direction: "up" | "down") => void; +}) { + const [localLabel, setLocalLabel] = useState(agg.label || ""); + const [localResultField, setLocalResultField] = useState(agg.resultField || ""); + const [localFormula, setLocalFormula] = useState(agg.formula || ""); + + useEffect(() => { + setLocalLabel(agg.label || ""); + setLocalResultField(agg.resultField || ""); + setLocalFormula(agg.formula || ""); + }, [agg.label, agg.resultField, agg.formula]); + + // 현재 집계보다 앞에 정의된 집계들만 참조 가능 (순환 참조 방지) + const referenceableAggregations = existingAggregations.slice(0, index); + + const currentSourceType = agg.sourceType || "column"; + const isFormula = currentSourceType === "formula"; + + return ( +
+ {/* 헤더 */} +
+
+ {/* 순서 변경 버튼 */} +
+ + +
+ + {isFormula ? "가상" : "집계"} {index + 1} + + {agg.label || "(라벨 없음)"} +
+ +
+ + {/* 집계 타입 선택 */} +
+
+ + +
+ +
+ + setLocalResultField(e.target.value)} + onBlur={() => onUpdate({ resultField: localResultField })} + placeholder="예: total_order_qty" + className="h-9 text-sm" + /> +
+
+ + {/* 컬럼 집계 설정 */} + {!isFormula && ( +
+
+ + +
+ +
+ + onUpdate({ sourceField: value })} + placeholder="컬럼 선택" + /> +
+ +
+ + +
+
+ )} + + {/* 가상 집계 (연산식) 설정 */} + {isFormula && ( +
+
+ +
+ {localFormula || "아래에서 요소를 추가하세요"} +
+
+ + {/* 연산자 */} +
+ +
+ {["+", "-", "*", "/", "(", ")"].map((op) => ( + + ))} + +
+
+ + {/* 이전 집계 참조 */} + {referenceableAggregations.length > 0 && ( +
+ +
+ {referenceableAggregations.map((refAgg) => ( + + ))} +
+
+ )} + + {/* 테이블 컬럼 집계 */} +
+ + { + const newFormula = localFormula + formulaPart; + setLocalFormula(newFormula); + onUpdate({ formula: newFormula }); + }} + /> +
+
+ )} + + {/* 라벨 및 숨김 설정 */} +
+
+ + setLocalLabel(e.target.value)} + onBlur={() => onUpdate({ label: localLabel })} + placeholder="예: 총수주량" + className="h-9 text-sm" + /> +
+
+ +
+ onUpdate({ hidden: checked })} + className="scale-90" + /> + + {agg.hidden ? "숨김" : "표시"} + +
+
+
+ {agg.hidden && ( +

+ 이 집계는 연산에만 사용되며 레이아웃에서 선택할 수 없습니다. +

+ )} +
+ ); +} + +// 수식에 테이블 컬럼 집계 추가하는 컴포넌트 +function FormulaColumnAggregator({ + sourceTable, + allTables, + onAdd, +}: { + sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + onAdd: (formulaPart: string) => void; +}) { + // 데이터 소스 타입: "current" (현재 카드), "external" (외부 테이블 행) + const [dataSourceType, setDataSourceType] = useState<"current" | "external">("current"); + const [selectedTable, setSelectedTable] = useState(sourceTable); + const [selectedColumn, setSelectedColumn] = useState(""); + const [selectedFunction, setSelectedFunction] = useState("SUM"); + + // 데이터 소스 타입 변경 시 테이블 초기화 + useEffect(() => { + if (dataSourceType === "current") { + setSelectedTable(sourceTable); + } + }, [dataSourceType, sourceTable]); + + const handleAdd = () => { + if (!selectedColumn) return; + + // 외부 데이터는 항상 _EXT 접미사 사용 + const funcName = dataSourceType === "external" ? `${selectedFunction}_EXT` : selectedFunction; + const formulaPart = `${funcName}({${selectedColumn}})`; + onAdd(formulaPart); + setSelectedColumn(""); + }; + + return ( +
+ {/* 데이터 소스 선택 */} +
+ + +
+ + {dataSourceType === "external" && ( +

+ 레이아웃의 테이블 행에서 조회한 외부 데이터를 집계합니다 (같은 품목의 다른 수주 등) +

+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+ ); +} + +// 🆕 레이아웃 설정 전용 모달 +function LayoutSettingsModal({ + open, + onOpenChange, + contentRows, + allTables, + dataSourceTable, + aggregations, + onSave, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + contentRows: CardContentRowConfig[]; + allTables: { tableName: string; displayName?: string }[]; + dataSourceTable: string; + aggregations: AggregationConfig[]; + onSave: (contentRows: CardContentRowConfig[]) => void; +}) { + // 로컬 상태로 행 목록 관리 + const [localRows, setLocalRows] = useState(contentRows); + + // 모달 열릴 때 초기화 + useEffect(() => { + if (open) { + setLocalRows(contentRows); + } + }, [open, contentRows]); + + // 행 추가 + const addRow = (type: CardContentRowConfig["type"]) => { + const newRow: CardContentRowConfig = { + id: `crow-${Date.now()}`, + type, + ...(type === "header" || type === "fields" + ? { columns: [], layout: "horizontal", gap: "16px" } + : {}), + ...(type === "aggregation" + ? { aggregationFields: [], aggregationLayout: "horizontal" } + : {}), + ...(type === "table" + ? { tableColumns: [], showTableHeader: true } + : {}), + }; + setLocalRows([...localRows, newRow]); + }; + + // 행 삭제 + const removeRow = (index: number) => { + const newRows = [...localRows]; + newRows.splice(index, 1); + setLocalRows(newRows); + }; + + // 행 업데이트 + const updateRow = (index: number, updates: Partial) => { + const newRows = [...localRows]; + newRows[index] = { ...newRows[index], ...updates }; + setLocalRows(newRows); + }; + + // 행 순서 변경 + const moveRow = (index: number, direction: "up" | "down") => { + const newIndex = direction === "up" ? index - 1 : index + 1; + if (newIndex < 0 || newIndex >= localRows.length) return; + + const newRows = [...localRows]; + [newRows[index], newRows[newIndex]] = [newRows[newIndex], newRows[index]]; + setLocalRows(newRows); + }; + + // 컬럼 추가 (header/fields용) + const addColumn = (rowIndex: number) => { + const newRows = [...localRows]; + const newCol: CardColumnConfig = { + id: `col-${Date.now()}`, + field: "", + label: "", + type: "text", + width: "auto", + editable: false, + }; + newRows[rowIndex].columns = [...(newRows[rowIndex].columns || []), newCol]; + setLocalRows(newRows); + }; + + // 컬럼 삭제 + const removeColumn = (rowIndex: number, colIndex: number) => { + const newRows = [...localRows]; + newRows[rowIndex].columns?.splice(colIndex, 1); + setLocalRows(newRows); + }; + + // 컬럼 업데이트 + const updateColumn = (rowIndex: number, colIndex: number, updates: Partial) => { + const newRows = [...localRows]; + if (newRows[rowIndex].columns) { + newRows[rowIndex].columns![colIndex] = { + ...newRows[rowIndex].columns![colIndex], + ...updates, + }; + } + setLocalRows(newRows); + }; + + // 집계 필드 추가 + const addAggField = (rowIndex: number) => { + const newRows = [...localRows]; + const newAggField: AggregationDisplayConfig = { + aggregationResultField: "", + label: "", + }; + newRows[rowIndex].aggregationFields = [...(newRows[rowIndex].aggregationFields || []), newAggField]; + setLocalRows(newRows); + }; + + // 집계 필드 삭제 + const removeAggField = (rowIndex: number, fieldIndex: number) => { + const newRows = [...localRows]; + newRows[rowIndex].aggregationFields?.splice(fieldIndex, 1); + setLocalRows(newRows); + }; + + // 집계 필드 업데이트 + const updateAggField = (rowIndex: number, fieldIndex: number, updates: Partial) => { + const newRows = [...localRows]; + if (newRows[rowIndex].aggregationFields) { + newRows[rowIndex].aggregationFields![fieldIndex] = { + ...newRows[rowIndex].aggregationFields![fieldIndex], + ...updates, + }; + } + setLocalRows(newRows); + }; + + // 집계 필드 순서 변경 + const moveAggField = (rowIndex: number, fieldIndex: number, direction: "up" | "down") => { + const newRows = [...localRows]; + 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]]; + setLocalRows(newRows); + }; + + // 테이블 컬럼 추가 + const addTableColumn = (rowIndex: number) => { + const newRows = [...localRows]; + const newCol: TableColumnConfig = { + id: `tcol-${Date.now()}`, + field: "", + label: "", + type: "text", + editable: false, + }; + newRows[rowIndex].tableColumns = [...(newRows[rowIndex].tableColumns || []), newCol]; + setLocalRows(newRows); + }; + + // 테이블 컬럼 삭제 + const removeTableColumn = (rowIndex: number, colIndex: number) => { + const newRows = [...localRows]; + newRows[rowIndex].tableColumns?.splice(colIndex, 1); + setLocalRows(newRows); + }; + + // 테이블 컬럼 업데이트 + const updateTableColumn = (rowIndex: number, colIndex: number, updates: Partial) => { + const newRows = [...localRows]; + if (newRows[rowIndex].tableColumns) { + newRows[rowIndex].tableColumns![colIndex] = { + ...newRows[rowIndex].tableColumns![colIndex], + ...updates, + }; + } + setLocalRows(newRows); + }; + + // 테이블 컬럼 순서 변경 + const moveTableColumn = (rowIndex: number, colIndex: number, direction: "up" | "down") => { + const newRows = [...localRows]; + const cols = newRows[rowIndex].tableColumns; + if (!cols) return; + + const newIndex = direction === "up" ? colIndex - 1 : colIndex + 1; + if (newIndex < 0 || newIndex >= cols.length) return; + + [cols[colIndex], cols[newIndex]] = [cols[newIndex], cols[colIndex]]; + setLocalRows(newRows); + }; + + // 저장 + const handleSave = () => { + onSave(localRows); + onOpenChange(false); + }; + + // 행 타입별 색상 + const getRowTypeColor = (type: CardContentRowConfig["type"]) => { + switch (type) { + case "header": return "bg-blue-100 border-blue-300"; + case "aggregation": return "bg-orange-100 border-orange-300"; + case "table": return "bg-green-100 border-green-300"; + case "fields": return "bg-purple-100 border-purple-300"; + default: return "bg-gray-100 border-gray-300"; + } + }; + + const getRowTypeLabel = (type: CardContentRowConfig["type"]) => { + switch (type) { + case "header": return "헤더"; + case "aggregation": return "집계"; + case "table": return "테이블"; + case "fields": return "필드"; + default: return type; + } + }; + + return ( + + + + 레이아웃 설정 + + 카드 내부의 행(헤더, 집계, 테이블, 필드)을 구성합니다. 각 행은 순서를 변경할 수 있습니다. + + + +
+ +
+ {/* 행 추가 버튼 */} +
+ + + + +
+ + {/* 행 목록 */} + {localRows.length === 0 ? ( +
+

레이아웃 행이 없습니다

+

+ 위의 버튼으로 헤더, 집계, 테이블, 필드 행을 추가하세요 +

+
+ ) : ( +
+ {localRows.map((row, index) => ( + updateRow(index, updates)} + onRemoveRow={() => removeRow(index)} + onMoveRow={(direction) => moveRow(index, direction)} + onAddColumn={() => addColumn(index)} + onRemoveColumn={(colIndex) => removeColumn(index, colIndex)} + onUpdateColumn={(colIndex, updates) => updateColumn(index, colIndex, updates)} + onAddAggField={() => addAggField(index)} + onRemoveAggField={(fieldIndex) => removeAggField(index, fieldIndex)} + onUpdateAggField={(fieldIndex, updates) => updateAggField(index, fieldIndex, updates)} + onMoveAggField={(fieldIndex, direction) => moveAggField(index, fieldIndex, direction)} + onAddTableColumn={() => addTableColumn(index)} + onRemoveTableColumn={(colIndex) => removeTableColumn(index, colIndex)} + onUpdateTableColumn={(colIndex, updates) => updateTableColumn(index, colIndex, updates)} + onMoveTableColumn={(colIndex, direction) => moveTableColumn(index, colIndex, direction)} + getRowTypeColor={getRowTypeColor} + getRowTypeLabel={getRowTypeLabel} + /> + ))} +
+ )} +
+
+
+ + + + + +
+
+ ); +} + +// 레이아웃 행 설정 (모달용) +function LayoutRowConfigModal({ + row, + rowIndex, + totalRows, + allTables, + dataSourceTable, + aggregations, + onUpdateRow, + onRemoveRow, + onMoveRow, + onAddColumn, + onRemoveColumn, + onUpdateColumn, + onAddAggField, + onRemoveAggField, + onUpdateAggField, + onMoveAggField, + onAddTableColumn, + onRemoveTableColumn, + onUpdateTableColumn, + onMoveTableColumn, + getRowTypeColor, + getRowTypeLabel, +}: { + row: CardContentRowConfig; + rowIndex: number; + totalRows: number; + allTables: { tableName: string; displayName?: string }[]; + dataSourceTable: string; + aggregations: AggregationConfig[]; + onUpdateRow: (updates: Partial) => void; + onRemoveRow: () => void; + onMoveRow: (direction: "up" | "down") => void; + onAddColumn: () => void; + onRemoveColumn: (colIndex: number) => void; + onUpdateColumn: (colIndex: number, updates: Partial) => void; + 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; + getRowTypeColor: (type: CardContentRowConfig["type"]) => string; + getRowTypeLabel: (type: CardContentRowConfig["type"]) => string; +}) { + const [isExpanded, setIsExpanded] = useState(true); + + return ( +
+ {/* 행 헤더 */} +
+
+ {/* 순서 변경 버튼 */} +
+ + +
+ {getRowTypeLabel(row.type)} {rowIndex + 1} + + {row.type === "header" || row.type === "fields" + ? `${(row.columns || []).length}개 컬럼` + : row.type === "aggregation" + ? `${(row.aggregationFields || []).length}개 필드` + : row.type === "table" + ? `${(row.tableColumns || []).length}개 컬럼` + : ""} + +
+
+ + +
+
+ + {/* 행 내용 */} + {isExpanded && ( +
+ {/* 헤더/필드 타입 */} + {(row.type === "header" || row.type === "fields") && ( +
+
+
+ + +
+
+ + +
+
+ + onUpdateRow({ gap: e.target.value })} + placeholder="16px" + className="h-8 text-xs" + /> +
+
+ + {/* 컬럼 목록 */} +
+
+ + +
+ {(row.columns || []).map((col, colIndex) => ( +
+
+ 컬럼 {colIndex + 1} + +
+
+
+ + onUpdateColumn(colIndex, { field: value })} + placeholder="필드 선택" + /> +
+
+ + onUpdateColumn(colIndex, { label: e.target.value })} + placeholder="라벨" + className="h-6 text-[10px]" + /> +
+
+ + +
+
+ + onUpdateColumn(colIndex, { width: e.target.value })} + placeholder="auto" + className="h-6 text-[10px]" + /> +
+
+
+ ))} +
+
+ )} + + {/* 집계 타입 */} + {row.type === "aggregation" && ( +
+
+
+ + +
+ {row.aggregationLayout === "grid" && ( +
+ + +
+ )} +
+ + {/* 집계 필드 목록 */} +
+
+ + +
+ {aggregations.filter(a => !a.hidden).length === 0 && ( +

+ 그룹 탭에서 먼저 집계를 설정해주세요 +

+ )} + {(row.aggregationFields || []).map((field, fieldIndex) => ( +
+
+
+ + + 집계 {fieldIndex + 1} +
+ +
+
+
+ + +
+
+ + onUpdateAggField(fieldIndex, { label: e.target.value })} + placeholder="라벨" + className="h-6 text-[10px]" + /> +
+
+ + +
+
+ + +
+
+
+ ))} +
+
+ )} + + {/* 테이블 타입 */} + {row.type === "table" && ( +
+
+
+ + onUpdateRow({ tableTitle: e.target.value })} + placeholder="테이블 제목" + className="h-8 text-xs" + /> +
+
+ +
+ onUpdateRow({ showTableHeader: checked })} + className="scale-90" + /> + {row.showTableHeader !== false ? "표시" : "숨김"} +
+
+
+ + onUpdateRow({ tableMaxHeight: e.target.value })} + placeholder="예: 300px" + className="h-8 text-xs" + /> +
+
+ + {/* 외부 데이터 소스 설정 */} +
+
+ + onUpdateRow({ + tableDataSource: { ...row.tableDataSource, enabled: checked, sourceTable: "", joinConditions: [] } + })} + className="scale-90" + /> +
+ {row.tableDataSource?.enabled && ( +
+
+ + +
+
+ )} +
+ + {/* 테이블 컬럼 목록 */} +
+
+ + +
+ {(row.tableColumns || []).map((col, colIndex) => ( +
+
+
+ + + 컬럼 {colIndex + 1} +
+ +
+
+
+ + onUpdateTableColumn(colIndex, { field: value })} + placeholder="필드 선택" + /> +
+
+ + onUpdateTableColumn(colIndex, { label: e.target.value })} + placeholder="라벨" + className="h-6 text-[10px]" + /> +
+
+ + +
+
+ +
+ onUpdateTableColumn(colIndex, { editable: checked })} + className="scale-75" + /> + {col.editable ? "예" : "아니오"} +
+
+
+
+ ))} +
+
+ )} +
+ )} +
+ ); +} + // 집계 설정 아이템 (로컬 상태 관리로 입력 시 리렌더링 방지) // 🆕 v3.2: 다중 테이블 및 가상 집계(formula) 지원 function AggregationConfigItem({ @@ -1194,6 +2601,12 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM const [allTables, setAllTables] = useState<{ tableName: string; displayName?: string }[]>([]); + // 집계 설정 모달 상태 + const [aggregationModalOpen, setAggregationModalOpen] = useState(false); + + // 레이아웃 설정 모달 상태 + const [layoutModalOpen, setLayoutModalOpen] = useState(false); + // 탭 상태 유지 (모듈 레벨 변수와 동기화) const [activeTab, setActiveTab] = useState(persistedActiveTab); @@ -1536,6 +2949,21 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM updateConfig({ contentRows: newRows }); }; + // 행(Row) 순서 변경 + const moveContentRow = (rowIndex: number, direction: "up" | "down") => { + const rows = localConfig.contentRows || []; + if (rows.length <= 1) return; + + const newIndex = direction === "up" ? rowIndex - 1 : rowIndex + 1; + if (newIndex < 0 || newIndex >= rows.length) return; + + // 행 위치 교환 + const newRows = [...rows]; + [newRows[rowIndex], newRows[newIndex]] = [newRows[newIndex], newRows[rowIndex]]; + + updateConfig({ contentRows: newRows }); + }; + // === (레거시) Simple 모드 행/컬럼 관련 함수 === const addRow = () => { const newRow: CardRowConfig = { @@ -1760,48 +3188,42 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM
-
- - -
+
-

- 컬럼 집계: 테이블 컬럼의 합계/개수 등 | 가상 집계: 연산식으로 계산 -

- - {(localConfig.grouping?.aggregations || []).map((agg, index) => ( - updateAggregation(index, updates)} - onRemove={() => removeAggregation(index)} - /> - ))} - - {(localConfig.grouping?.aggregations || []).length === 0 && ( + {/* 현재 집계 목록 요약 */} + {(localConfig.grouping?.aggregations || []).length > 0 ? ( +
+ {(localConfig.grouping?.aggregations || []).map((agg, index) => ( +
+ + {agg.hidden && [숨김]} + {agg.label || agg.resultField} + + + {agg.sourceType === "formula" ? "가상" : agg.type?.toUpperCase() || "SUM"} + +
+ ))} +
+ ) : (

집계 설정이 없습니다

@@ -1814,86 +3236,78 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM {/* === 레이아웃 설정 탭 === */} -
- {/* 행 추가 버튼들 */} -
-

행 추가

-
- -