feat: 신규 컴포넌트 2종 추가 (SimpleRepeaterTable, RepeatScreenModal) 및 속성 패널 스크롤 개선
- SimpleRepeaterTable: 검색/추가 없이 데이터 표시 및 편집, 자동 계산 지원 - RepeatScreenModal: 그룹핑 기반 카드 레이아웃, 집계 기능, 테이블 모드 지원 - UnifiedPropertiesPanel: overflow-x-auto 추가로 가로 스크롤 활성화
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Loader2, X } from "lucide-react";
|
||||
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { useCalculation } from "./useCalculation";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
export interface SimpleRepeaterTableComponentProps extends ComponentRendererProps {
|
||||
config?: SimpleRepeaterTableProps;
|
||||
// SimpleRepeaterTableProps의 개별 prop들도 지원 (호환성)
|
||||
value?: any[];
|
||||
onChange?: (newData: any[]) => void;
|
||||
columns?: SimpleRepeaterColumnConfig[];
|
||||
calculationRules?: any[];
|
||||
readOnly?: boolean;
|
||||
showRowNumber?: boolean;
|
||||
allowDelete?: boolean;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export function SimpleRepeaterTableComponent({
|
||||
// ComponentRendererProps (자동 전달)
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
className,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
|
||||
// SimpleRepeaterTable 전용 props
|
||||
config,
|
||||
value: propValue,
|
||||
onChange: propOnChange,
|
||||
columns: propColumns,
|
||||
calculationRules: propCalculationRules,
|
||||
readOnly: propReadOnly,
|
||||
showRowNumber: propShowRowNumber,
|
||||
allowDelete: propAllowDelete,
|
||||
maxHeight: propMaxHeight,
|
||||
|
||||
...props
|
||||
}: SimpleRepeaterTableComponentProps) {
|
||||
// config 또는 component.config 또는 개별 prop 우선순위로 병합
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component?.config,
|
||||
};
|
||||
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const columns = componentConfig?.columns || propColumns || [];
|
||||
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
|
||||
const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false;
|
||||
const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true;
|
||||
const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true;
|
||||
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
|
||||
|
||||
// value는 formData[columnName] 우선, 없으면 prop 사용
|
||||
const columnName = component?.columnName;
|
||||
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
||||
|
||||
// 🆕 로딩 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
|
||||
const handleChange = (newData: any[]) => {
|
||||
// 기존 onChange 콜백 호출 (호환성)
|
||||
const externalOnChange = componentConfig?.onChange || propOnChange;
|
||||
if (externalOnChange) {
|
||||
externalOnChange(newData);
|
||||
}
|
||||
|
||||
// onFormDataChange 호출하여 EditModal의 groupData 업데이트
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, newData);
|
||||
}
|
||||
};
|
||||
|
||||
// 계산 hook
|
||||
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
||||
|
||||
// 🆕 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
const initialConfig = componentConfig?.initialDataConfig;
|
||||
if (!initialConfig || !initialConfig.sourceTable) {
|
||||
return; // 초기 데이터 설정이 없으면 로드하지 않음
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
|
||||
try {
|
||||
// 필터 조건 생성
|
||||
const filters: Record<string, any> = {};
|
||||
|
||||
if (initialConfig.filterConditions) {
|
||||
for (const condition of initialConfig.filterConditions) {
|
||||
let filterValue = condition.value;
|
||||
|
||||
// formData에서 값 가져오기
|
||||
if (condition.valueFromField && formData) {
|
||||
filterValue = formData[condition.valueFromField];
|
||||
}
|
||||
|
||||
filters[condition.field] = filterValue;
|
||||
}
|
||||
}
|
||||
|
||||
// API 호출
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${initialConfig.sourceTable}/data`,
|
||||
{
|
||||
search: filters,
|
||||
page: 1,
|
||||
size: 1000, // 대량 조회
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.success && response.data.data?.data) {
|
||||
const loadedData = response.data.data.data;
|
||||
|
||||
// 1. 기본 데이터 매핑 (Direct & Manual)
|
||||
const baseMappedData = loadedData.map((row: any) => {
|
||||
const mappedRow: any = { ...row }; // 원본 데이터 유지 (조인 키 참조용)
|
||||
|
||||
for (const col of columns) {
|
||||
if (col.sourceConfig) {
|
||||
if (col.sourceConfig.type === "direct" && col.sourceConfig.sourceColumn) {
|
||||
mappedRow[col.field] = row[col.sourceConfig.sourceColumn];
|
||||
} else if (col.sourceConfig.type === "manual") {
|
||||
mappedRow[col.field] = col.defaultValue;
|
||||
}
|
||||
// Join은 2단계에서 처리
|
||||
} else {
|
||||
mappedRow[col.field] = row[col.field] ?? col.defaultValue;
|
||||
}
|
||||
}
|
||||
return mappedRow;
|
||||
});
|
||||
|
||||
// 2. 조인 데이터 처리
|
||||
const joinColumns = columns.filter(
|
||||
(col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey
|
||||
);
|
||||
|
||||
if (joinColumns.length > 0) {
|
||||
// 조인 테이블별로 그룹화
|
||||
const joinGroups = new Map<string, { key: string; refKey: string; cols: typeof columns }>();
|
||||
|
||||
joinColumns.forEach((col) => {
|
||||
const table = col.sourceConfig!.joinTable!;
|
||||
const key = col.sourceConfig!.joinKey!;
|
||||
// refKey가 없으면 key와 동일하다고 가정 (하위 호환성)
|
||||
const refKey = col.sourceConfig!.joinRefKey || key;
|
||||
const groupKey = `${table}:${key}:${refKey}`;
|
||||
|
||||
if (!joinGroups.has(groupKey)) {
|
||||
joinGroups.set(groupKey, { key, refKey, cols: [] });
|
||||
}
|
||||
joinGroups.get(groupKey)!.cols.push(col);
|
||||
});
|
||||
|
||||
// 각 그룹별로 데이터 조회 및 병합
|
||||
await Promise.all(
|
||||
Array.from(joinGroups.entries()).map(async ([groupKey, { key, refKey, cols }]) => {
|
||||
const [tableName] = groupKey.split(":");
|
||||
|
||||
// 조인 키 값 수집 (중복 제거)
|
||||
const keyValues = Array.from(new Set(
|
||||
baseMappedData
|
||||
.map((row: any) => row[key])
|
||||
.filter((v: any) => v !== undefined && v !== null)
|
||||
));
|
||||
|
||||
if (keyValues.length === 0) return;
|
||||
|
||||
try {
|
||||
// 조인 테이블 조회
|
||||
// refKey(타겟 테이블 컬럼)로 검색
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{
|
||||
search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
|
||||
page: 1,
|
||||
size: 1000,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.success && response.data.data?.data) {
|
||||
const joinedRows = response.data.data.data;
|
||||
// 조인 데이터 맵 생성 (refKey -> row)
|
||||
const joinMap = new Map(joinedRows.map((r: any) => [r[refKey], r]));
|
||||
|
||||
// 데이터 병합
|
||||
baseMappedData.forEach((row: any) => {
|
||||
const keyValue = row[key];
|
||||
const joinedRow = joinMap.get(keyValue);
|
||||
|
||||
if (joinedRow) {
|
||||
cols.forEach((col) => {
|
||||
if (col.sourceConfig?.joinColumn) {
|
||||
row[col.field] = joinedRow[col.sourceConfig.joinColumn];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`조인 실패 (${tableName}):`, error);
|
||||
// 실패 시 무시하고 진행 (값은 undefined)
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const mappedData = baseMappedData;
|
||||
|
||||
// 계산 필드 적용
|
||||
const calculatedData = calculateAll(mappedData);
|
||||
handleChange(calculatedData);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("초기 데이터 로드 실패:", error);
|
||||
setLoadError(error.message || "데이터를 불러올 수 없습니다");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [componentConfig?.initialDataConfig]);
|
||||
|
||||
// 초기 데이터에 계산 필드 적용
|
||||
useEffect(() => {
|
||||
if (value.length > 0 && calculationRules.length > 0) {
|
||||
const calculated = calculateAll(value);
|
||||
// 값이 실제로 변경된 경우만 업데이트
|
||||
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
|
||||
handleChange(calculated);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 🆕 저장 요청 시 테이블별로 데이터 그룹화 (beforeFormSave 이벤트 리스너)
|
||||
useEffect(() => {
|
||||
const handleSaveRequest = async (event: Event) => {
|
||||
if (value.length === 0) {
|
||||
console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 테이블별로 데이터 그룹화
|
||||
const dataByTable: Record<string, any[]> = {};
|
||||
|
||||
for (const row of value) {
|
||||
// 각 행의 데이터를 테이블별로 분리
|
||||
for (const col of columns) {
|
||||
// 저장 설정이 있고 저장이 활성화된 경우에만
|
||||
if (col.targetConfig && col.targetConfig.targetTable && col.targetConfig.saveEnabled !== false) {
|
||||
const targetTable = col.targetConfig.targetTable;
|
||||
const targetColumn = col.targetConfig.targetColumn || col.field;
|
||||
|
||||
// 테이블 그룹 초기화
|
||||
if (!dataByTable[targetTable]) {
|
||||
dataByTable[targetTable] = [];
|
||||
}
|
||||
|
||||
// 해당 테이블의 데이터 찾기 또는 생성
|
||||
let tableRow = dataByTable[targetTable].find((r: any) => r._rowIndex === row._rowIndex);
|
||||
if (!tableRow) {
|
||||
tableRow = { _rowIndex: row._rowIndex };
|
||||
dataByTable[targetTable].push(tableRow);
|
||||
}
|
||||
|
||||
// 컬럼 값 저장
|
||||
tableRow[targetColumn] = row[col.field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// _rowIndex 제거
|
||||
Object.keys(dataByTable).forEach((tableName) => {
|
||||
dataByTable[tableName] = dataByTable[tableName].map((row: any) => {
|
||||
const { _rowIndex, ...rest } = row;
|
||||
return rest;
|
||||
});
|
||||
});
|
||||
|
||||
console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
|
||||
|
||||
// CustomEvent의 detail에 테이블별 데이터 추가
|
||||
if (event instanceof CustomEvent && event.detail) {
|
||||
// 각 테이블별로 데이터 전달
|
||||
Object.entries(dataByTable).forEach(([tableName, rows]) => {
|
||||
const key = `${columnName || component?.id}_${tableName}`;
|
||||
event.detail.formData[key] = rows.map((row: any) => ({
|
||||
...row,
|
||||
_targetTable: tableName,
|
||||
}));
|
||||
});
|
||||
|
||||
console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
|
||||
tables: Object.keys(dataByTable),
|
||||
totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
|
||||
});
|
||||
}
|
||||
|
||||
// 기존 onFormDataChange도 호출 (호환성)
|
||||
if (onFormDataChange && columnName) {
|
||||
// 테이블별 데이터를 통합하여 전달
|
||||
onFormDataChange(columnName, Object.entries(dataByTable).flatMap(([table, rows]) =>
|
||||
rows.map((row: any) => ({ ...row, _targetTable: table }))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 저장 버튼 클릭 시 데이터 수집
|
||||
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||
};
|
||||
}, [value, columns, columnName, component?.id, onFormDataChange]);
|
||||
|
||||
const handleCellEdit = (rowIndex: number, field: string, cellValue: any) => {
|
||||
const newRow = { ...value[rowIndex], [field]: cellValue };
|
||||
|
||||
// 계산 필드 업데이트
|
||||
const calculatedRow = calculateRow(newRow);
|
||||
|
||||
const newData = [...value];
|
||||
newData[rowIndex] = calculatedRow;
|
||||
handleChange(newData);
|
||||
};
|
||||
|
||||
const handleRowDelete = (rowIndex: number) => {
|
||||
const newData = value.filter((_, i) => i !== rowIndex);
|
||||
handleChange(newData);
|
||||
};
|
||||
|
||||
const renderCell = (
|
||||
row: any,
|
||||
column: SimpleRepeaterColumnConfig,
|
||||
rowIndex: number
|
||||
) => {
|
||||
const cellValue = row[column.field];
|
||||
|
||||
// 계산 필드는 편집 불가
|
||||
if (column.calculated || !column.editable || readOnly) {
|
||||
return (
|
||||
<div className="px-2 py-1">
|
||||
{column.type === "number"
|
||||
? typeof cellValue === "number"
|
||||
? cellValue.toLocaleString()
|
||||
: cellValue || "0"
|
||||
: cellValue || "-"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 편집 가능한 필드
|
||||
switch (column.type) {
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={cellValue || ""}
|
||||
onChange={(e) =>
|
||||
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={cellValue || ""}
|
||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={cellValue || ""}
|
||||
onValueChange={(newValue) =>
|
||||
handleCellEdit(rowIndex, column.field, newValue)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{column.selectOptions?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
default: // text
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={cellValue || ""}
|
||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 중일 때
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
||||
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 발생 시
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
||||
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-2">
|
||||
<X className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-destructive mb-1">데이터 로드 실패</p>
|
||||
<p className="text-xs text-muted-foreground">{loadError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
||||
<div
|
||||
className="overflow-x-auto overflow-y-auto"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
{showRowNumber && (
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||
#
|
||||
</th>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.field}
|
||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
{col.label}
|
||||
{col.required && <span className="text-destructive ml-1">*</span>}
|
||||
</th>
|
||||
))}
|
||||
{!readOnly && allowDelete && (
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
|
||||
삭제
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-background">
|
||||
{value.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0)}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
표시할 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
value.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-t hover:bg-accent/50">
|
||||
{showRowNumber && (
|
||||
<td className="px-4 py-2 text-center text-muted-foreground">
|
||||
{rowIndex + 1}
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<td key={col.field} className="px-2 py-1">
|
||||
{renderCell(row, col, rowIndex)}
|
||||
</td>
|
||||
))}
|
||||
{!readOnly && allowDelete && (
|
||||
<td className="px-4 py-2 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRowDelete(rowIndex)}
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import { SimpleRepeaterTableDefinition } from "./index";
|
||||
import { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
ComponentRegistry.registerComponent(SimpleRepeaterTableDefinition);
|
||||
|
||||
console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료");
|
||||
|
||||
export function SimpleRepeaterTableRenderer(props: ComponentRendererProps) {
|
||||
return <SimpleRepeaterTableComponent {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
|
||||
import { SimpleRepeaterTableConfigPanel } from "./SimpleRepeaterTableConfigPanel";
|
||||
|
||||
/**
|
||||
* 🆕 SimpleRepeaterTable 컴포넌트 정의
|
||||
* 단순 반복 테이블 - 검색/추가 없이 데이터 표시 및 편집만
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 초기 데이터 로드: 어떤 테이블에서 어떤 조건으로 데이터를 가져올지 설정
|
||||
* - 컬럼별 소스 설정: 각 컬럼의 데이터를 어디서 조회할지 설정 (직접 조회/조인 조회/수동 입력)
|
||||
* - 컬럼별 타겟 설정: 각 컬럼의 데이터를 어느 테이블의 어느 컬럼에 저장할지 설정
|
||||
* - 자동 계산: 수량 * 단가 = 금액 같은 자동 계산 지원
|
||||
* - 읽기 전용 모드: 전체 테이블을 보기 전용으로 설정
|
||||
*/
|
||||
export const SimpleRepeaterTableDefinition = createComponentDefinition({
|
||||
id: "simple-repeater-table",
|
||||
name: "단순 반복 테이블",
|
||||
nameEng: "Simple Repeater Table",
|
||||
description: "어떤 테이블에서 조회하고 어떤 테이블에 저장할지 컬럼별로 설정 가능한 반복 테이블 (검색/추가 없음, 자동 계산 지원)",
|
||||
category: ComponentCategory.DATA,
|
||||
webType: "table",
|
||||
component: SimpleRepeaterTableComponent,
|
||||
defaultConfig: {
|
||||
columns: [],
|
||||
calculationRules: [],
|
||||
initialDataConfig: undefined,
|
||||
readOnly: false,
|
||||
showRowNumber: true,
|
||||
allowDelete: true,
|
||||
maxHeight: "240px",
|
||||
},
|
||||
defaultSize: { width: 800, height: 400 },
|
||||
configPanel: SimpleRepeaterTableConfigPanel,
|
||||
icon: "Table",
|
||||
tags: ["테이블", "반복", "편집", "데이터", "목록", "계산", "조회", "저장"],
|
||||
version: "2.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type {
|
||||
SimpleRepeaterTableProps,
|
||||
SimpleRepeaterColumnConfig,
|
||||
CalculationRule,
|
||||
ColumnSourceConfig,
|
||||
ColumnTargetConfig,
|
||||
InitialDataConfig,
|
||||
DataFilterCondition,
|
||||
SourceJoinCondition,
|
||||
} from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
|
||||
export { SimpleRepeaterTableConfigPanel } from "./SimpleRepeaterTableConfigPanel";
|
||||
export { useCalculation } from "./useCalculation";
|
||||
|
||||
132
frontend/lib/registry/components/simple-repeater-table/types.ts
Normal file
132
frontend/lib/registry/components/simple-repeater-table/types.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* SimpleRepeaterTable 컴포넌트 타입 정의
|
||||
* 데이터 검색/추가 없이 주어진 데이터를 표시하고 편집하는 경량 테이블
|
||||
*/
|
||||
|
||||
export interface SimpleRepeaterTableProps {
|
||||
// 데이터
|
||||
value?: any[]; // 현재 표시할 데이터
|
||||
onChange?: (newData: any[]) => void; // 데이터 변경 콜백
|
||||
|
||||
// 테이블 설정
|
||||
columns: SimpleRepeaterColumnConfig[]; // 테이블 컬럼 설정
|
||||
|
||||
// 🆕 초기 데이터 로드 설정
|
||||
initialDataConfig?: InitialDataConfig;
|
||||
|
||||
// 계산 규칙
|
||||
calculationRules?: CalculationRule[]; // 자동 계산 규칙 (수량 * 단가 = 금액)
|
||||
|
||||
// 옵션
|
||||
readOnly?: boolean; // 읽기 전용 모드 (편집 불가)
|
||||
showRowNumber?: boolean; // 행 번호 표시 (기본: true)
|
||||
allowDelete?: boolean; // 삭제 버튼 표시 (기본: true)
|
||||
maxHeight?: string; // 테이블 최대 높이 (기본: "240px")
|
||||
|
||||
// 스타일
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SimpleRepeaterColumnConfig {
|
||||
field: string; // 필드명 (화면에 표시용 임시 키)
|
||||
label: string; // 컬럼 헤더 라벨
|
||||
type?: "text" | "number" | "date" | "select"; // 입력 타입
|
||||
editable?: boolean; // 편집 가능 여부
|
||||
calculated?: boolean; // 계산 필드 여부 (자동 계산되는 필드)
|
||||
width?: string; // 컬럼 너비
|
||||
required?: boolean; // 필수 입력 여부
|
||||
defaultValue?: any; // 기본값
|
||||
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
|
||||
|
||||
// 🆕 데이터 조회 설정 (어디서 가져올지)
|
||||
sourceConfig?: ColumnSourceConfig;
|
||||
|
||||
// 🆕 데이터 저장 설정 (어디에 저장할지)
|
||||
targetConfig?: ColumnTargetConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 데이터 조회 설정
|
||||
* 어떤 테이블에서 어떤 컬럼을 어떤 조건으로 조회할지 정의
|
||||
*/
|
||||
export interface ColumnSourceConfig {
|
||||
/** 조회 타입 */
|
||||
type: "direct" | "join" | "manual";
|
||||
|
||||
// type: "direct" - 직접 조회 (단일 테이블에서 바로 가져오기)
|
||||
sourceTable?: string; // 조회할 테이블 (예: "sales_order_mng")
|
||||
sourceColumn?: string; // 조회할 컬럼 (예: "item_name")
|
||||
|
||||
// type: "join" - 조인 조회 (다른 테이블과 조인하여 가져오기)
|
||||
joinTable?: string; // 조인할 테이블 (예: "customer_item_mapping")
|
||||
joinColumn?: string; // 조인 테이블에서 가져올 컬럼 (예: "basic_price")
|
||||
joinKey?: string; // 🆕 조인 키 (현재 테이블의 컬럼, 예: "sales_order_id")
|
||||
joinRefKey?: string; // 🆕 참조 키 (조인 테이블의 컬럼, 예: "id")
|
||||
joinConditions?: SourceJoinCondition[]; // 조인 조건 (어떤 키로 조인할지)
|
||||
|
||||
// type: "manual" - 사용자 직접 입력 (조회 안 함)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 데이터 저장 설정
|
||||
* 어떤 테이블의 어떤 컬럼에 저장할지 정의
|
||||
*/
|
||||
export interface ColumnTargetConfig {
|
||||
targetTable?: string; // 저장할 테이블 (예: "shipment_plan")
|
||||
targetColumn?: string; // 저장할 컬럼 (예: "plan_qty")
|
||||
saveEnabled?: boolean; // 저장 활성화 여부 (false면 읽기 전용)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 소스 조인 조건
|
||||
* 데이터를 조회할 때 어떤 키로 조인할지 정의
|
||||
*/
|
||||
export interface SourceJoinCondition {
|
||||
/** 기준 테이블 */
|
||||
baseTable: string; // 기준이 되는 테이블 (예: "sales_order_mng")
|
||||
/** 기준 컬럼 */
|
||||
baseColumn: string; // 기준 테이블의 컬럼 (예: "item_code")
|
||||
/** 조인 테이블의 컬럼 */
|
||||
joinColumn: string; // 조인 테이블에서 매칭할 컬럼 (예: "item_code")
|
||||
/** 비교 연산자 */
|
||||
operator?: "=" | "!=" | ">" | "<" | ">=" | "<=";
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 초기 데이터 로드 설정
|
||||
* 컴포넌트가 로드될 때 어떤 데이터를 가져올지
|
||||
*/
|
||||
export interface InitialDataConfig {
|
||||
/** 로드할 테이블 */
|
||||
sourceTable: string; // 예: "sales_order_mng"
|
||||
|
||||
/** 필터 조건 */
|
||||
filterConditions?: DataFilterCondition[];
|
||||
|
||||
/** 선택할 컬럼 목록 */
|
||||
selectColumns?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 필터 조건
|
||||
*/
|
||||
export interface DataFilterCondition {
|
||||
/** 필드명 */
|
||||
field: string;
|
||||
/** 연산자 */
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
|
||||
/** 값 (또는 다른 필드 참조) */
|
||||
value: any;
|
||||
/** 값을 다른 필드에서 가져올지 */
|
||||
valueFromField?: string; // 예: "order_no" (formData에서 가져오기)
|
||||
}
|
||||
|
||||
/**
|
||||
* 계산 규칙 (자동 계산)
|
||||
*/
|
||||
export interface CalculationRule {
|
||||
result: string; // 결과를 저장할 필드 (예: "total_amount")
|
||||
formula: string; // 계산 공식 (예: "quantity * unit_price")
|
||||
dependencies: string[]; // 의존하는 필드들 (예: ["quantity", "unit_price"])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback } from "react";
|
||||
import { CalculationRule } from "./types";
|
||||
|
||||
/**
|
||||
* 계산 필드 자동 업데이트 훅
|
||||
*/
|
||||
export function useCalculation(calculationRules: CalculationRule[] = []) {
|
||||
/**
|
||||
* 단일 행의 계산 필드 업데이트
|
||||
*/
|
||||
const calculateRow = useCallback(
|
||||
(row: any): any => {
|
||||
if (calculationRules.length === 0) return row;
|
||||
|
||||
const updatedRow = { ...row };
|
||||
|
||||
for (const rule of calculationRules) {
|
||||
try {
|
||||
// formula에서 필드명 자동 추출 (영문자, 숫자, 언더스코어로 구성된 단어)
|
||||
let formula = rule.formula;
|
||||
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
|
||||
|
||||
// 추출된 필드명들을 사용 (dependencies가 없으면 자동 추출 사용)
|
||||
const dependencies = rule.dependencies && rule.dependencies.length > 0
|
||||
? rule.dependencies
|
||||
: fieldMatches;
|
||||
|
||||
// 필드명을 실제 값으로 대체
|
||||
for (const dep of dependencies) {
|
||||
// 결과 필드는 제외
|
||||
if (dep === rule.result) continue;
|
||||
|
||||
const value = parseFloat(row[dep]) || 0;
|
||||
// 정확한 필드명만 대체 (단어 경계 사용)
|
||||
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
|
||||
}
|
||||
|
||||
// 계산 실행 (Function 사용)
|
||||
const result = new Function(`return ${formula}`)();
|
||||
updatedRow[rule.result] = result;
|
||||
} catch (error) {
|
||||
console.error(`계산 오류 (${rule.formula}):`, error);
|
||||
updatedRow[rule.result] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRow;
|
||||
},
|
||||
[calculationRules]
|
||||
);
|
||||
|
||||
/**
|
||||
* 전체 데이터의 계산 필드 업데이트
|
||||
*/
|
||||
const calculateAll = useCallback(
|
||||
(data: any[]): any[] => {
|
||||
return data.map((row) => calculateRow(row));
|
||||
},
|
||||
[calculateRow]
|
||||
);
|
||||
|
||||
return {
|
||||
calculateRow,
|
||||
calculateAll,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user