feat(UniversalFormModal): 테이블 섹션 기능 추가

- FormSectionConfig에 type("fields"|"table") 및 tableConfig 필드 추가
- TableSectionRenderer, TableSectionSettingsModal 신규 컴포넌트 생성
- ItemSelectionModal에 모달 필터 기능 추가 (소스 테이블 distinct 값 조회)
- 설정 패널에서 테이블 섹션 추가/설정 UI 구현
This commit is contained in:
SeongHyun Kim 2025-12-18 15:19:59 +09:00
parent 8687c88f70
commit 1c6eb2ae61
13 changed files with 3455 additions and 87 deletions

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import {
Dialog,
DialogContent,
@ -12,9 +12,11 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Loader2 } from "lucide-react";
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
import { ItemSelectionModalProps } from "./types";
import { ItemSelectionModalProps, ModalFilterConfig } from "./types";
import { apiClient } from "@/lib/api/client";
export function ItemSelectionModal({
open,
@ -29,27 +31,134 @@ export function ItemSelectionModal({
uniqueField,
onSelect,
columnLabels = {},
modalFilters = [],
}: ItemSelectionModalProps) {
const [localSearchText, setLocalSearchText] = useState("");
const [selectedItems, setSelectedItems] = useState<any[]>([]);
// 모달 필터 값 상태
const [modalFilterValues, setModalFilterValues] = useState<Record<string, any>>({});
// 카테고리 옵션 상태 (categoryRef별로 로드된 옵션)
const [categoryOptions, setCategoryOptions] = useState<Record<string, { value: string; label: string }[]>>({});
// 모달 필터 값과 기본 filterCondition을 합친 최종 필터 조건
const combinedFilterCondition = useMemo(() => {
const combined = { ...filterCondition };
// 모달 필터 값 추가 (빈 값은 제외)
for (const [key, value] of Object.entries(modalFilterValues)) {
if (value !== undefined && value !== null && value !== "") {
combined[key] = value;
}
}
return combined;
}, [filterCondition, modalFilterValues]);
const { results, loading, error, search, clearSearch } = useEntitySearch({
tableName: sourceTable,
searchFields: sourceSearchFields.length > 0 ? sourceSearchFields : sourceColumns,
filterCondition,
filterCondition: combinedFilterCondition,
});
// 모달 열릴 때 초기 검색
// 필터 옵션 로드 - 소스 테이블 컬럼의 distinct 값 조회
const loadFilterOptions = async (filter: ModalFilterConfig) => {
// 드롭다운 타입만 옵션 로드 필요 (select, category 지원)
const isDropdownType = filter.type === "select" || filter.type === "category";
if (!isDropdownType) return;
const cacheKey = `${sourceTable}.${filter.column}`;
// 이미 로드된 경우 스킵
if (categoryOptions[cacheKey]) return;
try {
// 소스 테이블에서 해당 컬럼의 데이터 조회 (POST 메서드 사용)
// 백엔드는 'size' 파라미터를 사용함
const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, {
page: 1,
size: 10000, // 모든 데이터 조회를 위해 큰 값 설정
});
if (response.data?.success) {
// 응답 구조에 따라 rows 추출
const rows = response.data.data?.rows || response.data.data?.data || response.data.data || [];
if (Array.isArray(rows)) {
// 컬럼 값 중복 제거
const uniqueValues = new Set<string>();
for (const row of rows) {
const val = row[filter.column];
if (val !== null && val !== undefined && val !== "") {
uniqueValues.add(String(val));
}
}
// 정렬 후 옵션으로 변환
const options = Array.from(uniqueValues)
.sort()
.map((val) => ({
value: val,
label: val,
}));
setCategoryOptions((prev) => ({
...prev,
[cacheKey]: options,
}));
}
}
} catch (error) {
console.error(`필터 옵션 로드 실패 (${cacheKey}):`, error);
setCategoryOptions((prev) => ({
...prev,
[cacheKey]: [],
}));
}
};
// 모달 열릴 때 초기 검색 및 필터 초기화
useEffect(() => {
if (open) {
// 모달 필터 기본값 설정 & 옵션 로드
const initialFilterValues: Record<string, any> = {};
for (const filter of modalFilters) {
if (filter.defaultValue !== undefined) {
initialFilterValues[filter.column] = filter.defaultValue;
}
// 드롭다운 타입이면 옵션 로드 (소스 테이블에서 distinct 값 조회)
const isDropdownType = filter.type === "select" || filter.type === "category";
if (isDropdownType) {
loadFilterOptions(filter);
}
}
setModalFilterValues(initialFilterValues);
search("", 1); // 빈 검색어로 전체 목록 조회
setSelectedItems([]);
} else {
clearSearch();
setLocalSearchText("");
setSelectedItems([]);
setModalFilterValues({});
}
}, [open]);
// 모달 필터 값 변경 시 재검색
useEffect(() => {
if (open) {
search(localSearchText, 1);
}
}, [modalFilterValues]);
// 모달 필터 값 변경 핸들러
const handleModalFilterChange = (column: string, value: any) => {
setModalFilterValues((prev) => ({
...prev,
[column]: value,
}));
};
const handleSearch = () => {
search(localSearchText, 1);
@ -202,6 +311,51 @@ export function ItemSelectionModal({
</Button>
</div>
{/* 모달 필터 */}
{modalFilters.length > 0 && (
<div className="flex flex-wrap gap-3 items-center py-2 px-1 bg-muted/30 rounded-md">
{modalFilters.map((filter) => {
// 소스 테이블의 해당 컬럼에서 로드된 옵션
const options = categoryOptions[`${sourceTable}.${filter.column}`] || [];
// 드롭다운 타입인지 확인 (select, category 모두 드롭다운으로 처리)
const isDropdownType = filter.type === "select" || filter.type === "category";
return (
<div key={filter.column} className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">{filter.label}:</span>
{isDropdownType && (
<Select
value={modalFilterValues[filter.column] || "__all__"}
onValueChange={(value) => handleModalFilterChange(filter.column, value === "__all__" ? "" : value)}
>
<SelectTrigger className="h-7 text-xs w-[140px]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value || `__empty_${opt.label}__`}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{filter.type === "text" && (
<Input
value={modalFilterValues[filter.column] || ""}
onChange={(e) => handleModalFilterChange(filter.column, e.target.value)}
placeholder={filter.label}
className="h-7 text-xs w-[120px]"
/>
)}
</div>
);
})}
</div>
)}
{/* 선택된 항목 수 */}
{selectedItems.length > 0 && (
<div className="text-sm text-primary">

View File

@ -205,6 +205,9 @@ export function ModalRepeaterTableComponent({
const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true;
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
// 모달 필터 설정
const modalFilters = componentConfig?.modalFilters || [];
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
const columnName = component?.columnName;
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
@ -889,6 +892,7 @@ export function ModalRepeaterTableComponent({
uniqueField={uniqueField}
onSelect={handleAddItems}
columnLabels={columnLabels}
modalFilters={modalFilters}
/>
</div>
);

View File

@ -9,7 +9,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep } from "./types";
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep, ModalFilterConfig } from "./types";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
@ -842,6 +842,97 @@ export function ModalRepeaterTableConfigPanel({
/>
</div>
</div>
{/* 모달 필터 설정 */}
<div className="space-y-2 pt-4 border-t">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const filters = localConfig.modalFilters || [];
updateConfig({
modalFilters: [...filters, { column: "", label: "", type: "select" }],
});
}}
className="h-7 text-xs"
disabled={!localConfig.sourceTable}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
. .
</p>
{(localConfig.modalFilters || []).length > 0 && (
<div className="space-y-2 mt-2">
{(localConfig.modalFilters || []).map((filter, index) => (
<div key={index} className="flex items-center gap-2 p-2 border rounded-md bg-muted/30">
<Select
value={filter.column}
onValueChange={(value) => {
const filters = [...(localConfig.modalFilters || [])];
filters[index] = { ...filters[index], column: value };
updateConfig({ modalFilters: filters });
}}
disabled={!localConfig.sourceTable || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs w-[140px]">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.label}
onChange={(e) => {
const filters = [...(localConfig.modalFilters || [])];
filters[index] = { ...filters[index], label: e.target.value };
updateConfig({ modalFilters: filters });
}}
placeholder="라벨"
className="h-8 text-xs w-[100px]"
/>
<Select
value={filter.type}
onValueChange={(value: "select" | "text") => {
const filters = [...(localConfig.modalFilters || [])];
filters[index] = { ...filters[index], type: value };
updateConfig({ modalFilters: filters });
}}
>
<SelectTrigger className="h-8 text-xs w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="select"></SelectItem>
<SelectItem value="text"></SelectItem>
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
onClick={() => {
const filters = [...(localConfig.modalFilters || [])];
filters.splice(index, 1);
updateConfig({ modalFilters: filters });
}}
className="h-7 w-7 text-destructive hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
{/* 반복 테이블 컬럼 관리 */}

View File

@ -17,6 +17,7 @@ export interface ModalRepeaterTableProps {
modalTitle: string; // 모달 제목 (예: "품목 검색 및 선택")
modalButtonText?: string; // 모달 열기 버튼 텍스트 (기본: "품목 검색")
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
modalFilters?: ModalFilterConfig[]; // 모달 내 필터 설정
// Repeater 테이블 설정
columns: RepeaterColumnConfig[]; // 테이블 컬럼 설정
@ -175,6 +176,14 @@ export interface CalculationRule {
dependencies: string[]; // 의존하는 필드들
}
// 모달 필터 설정 (간소화된 버전)
export interface ModalFilterConfig {
column: string; // 필터 대상 컬럼 (소스 테이블의 컬럼명)
label: string; // 필터 라벨 (UI에 표시될 이름)
type: "select" | "text"; // select: 드롭다운 (distinct 값), text: 텍스트 입력
defaultValue?: string; // 기본값
}
export interface ItemSelectionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -188,4 +197,7 @@ export interface ItemSelectionModalProps {
uniqueField?: string;
onSelect: (items: Record<string, unknown>[]) => void;
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
// 모달 내부 필터 (사용자 선택 가능)
modalFilters?: ModalFilterConfig[];
}

View File

@ -0,0 +1,502 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Plus, Columns } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
// 기존 ModalRepeaterTable 컴포넌트 재사용
import { RepeaterTable } from "../modal-repeater-table/RepeaterTable";
import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal";
import { RepeaterColumnConfig, CalculationRule, DynamicDataSourceOption } from "../modal-repeater-table/types";
// 타입 정의
import {
TableSectionConfig,
TableColumnConfig,
ValueMappingConfig,
TableJoinCondition,
FormDataState,
} from "./types";
interface TableSectionRendererProps {
sectionId: string;
tableConfig: TableSectionConfig;
formData: FormDataState;
onFormDataChange: (field: string, value: any) => void;
onTableDataChange: (data: any[]) => void;
className?: string;
}
/**
* TableColumnConfig를 RepeaterColumnConfig로
* columnModes가 dynamicDataSource로
*/
function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
const baseColumn: RepeaterColumnConfig = {
field: col.field,
label: col.label,
type: col.type,
editable: col.editable ?? true,
calculated: col.calculated ?? false,
width: col.width || "150px",
required: col.required,
defaultValue: col.defaultValue,
selectOptions: col.selectOptions,
// valueMapping은 별도로 처리
};
// columnModes를 dynamicDataSource로 변환
if (col.columnModes && col.columnModes.length > 0) {
baseColumn.dynamicDataSource = {
enabled: true,
options: col.columnModes.map((mode) => ({
id: mode.id,
label: mode.label,
sourceType: "table" as const,
// 실제 조회 로직은 TableSectionRenderer에서 처리
tableConfig: {
tableName: mode.valueMapping?.externalRef?.tableName || "",
valueField: mode.valueMapping?.externalRef?.valueColumn || "",
joinConditions: (mode.valueMapping?.externalRef?.joinConditions || []).map((jc) => ({
sourceTable: jc.sourceType === "row" ? "target" : "source",
sourceField: jc.sourceField,
targetField: jc.targetColumn,
operator: jc.operator || "=",
})),
},
})),
defaultOptionId: col.columnModes.find((m) => m.isDefault)?.id || col.columnModes[0]?.id,
};
}
return baseColumn;
}
/**
* TableCalculationRule을 CalculationRule로
*/
function convertToCalculationRule(calc: { resultField: string; formula: string; dependencies: string[] }): CalculationRule {
return {
result: calc.resultField,
formula: calc.formula,
dependencies: calc.dependencies,
};
}
/**
*
*/
async function fetchExternalValue(
tableName: string,
valueColumn: string,
joinConditions: TableJoinCondition[],
rowData: any,
formData: FormDataState
): Promise<any> {
if (joinConditions.length === 0) {
console.warn("조인 조건이 없습니다.");
return undefined;
}
try {
const whereConditions: Record<string, any> = {};
for (const condition of joinConditions) {
let value: any;
// 값 출처에 따라 가져오기
if (condition.sourceType === "row") {
// 현재 행에서 가져오기
value = rowData[condition.sourceField];
} else if (condition.sourceType === "formData") {
// formData에서 가져오기 (핵심 기능!)
value = formData[condition.sourceField];
}
if (value === undefined || value === null) {
console.warn(`조인 조건의 필드 "${condition.sourceField}" 값이 없습니다. (sourceType: ${condition.sourceType})`);
return undefined;
}
// 숫자형 ID 변환
let convertedValue = value;
if (condition.targetColumn.endsWith("_id") || condition.targetColumn === "id") {
const numValue = Number(value);
if (!isNaN(numValue)) {
convertedValue = numValue;
}
}
whereConditions[condition.targetColumn] = convertedValue;
}
// API 호출
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{ search: whereConditions, size: 1, page: 1 }
);
if (response.data.success && response.data.data?.data?.length > 0) {
return response.data.data.data[0][valueColumn];
}
return undefined;
} catch (error) {
console.error("외부 테이블 조회 오류:", error);
return undefined;
}
}
/**
*
* UniversalFormModal
*/
export function TableSectionRenderer({
sectionId,
tableConfig,
formData,
onFormDataChange,
onTableDataChange,
className,
}: TableSectionRendererProps) {
// 테이블 데이터 상태
const [tableData, setTableData] = useState<any[]>([]);
// 모달 상태
const [modalOpen, setModalOpen] = useState(false);
// 체크박스 선택 상태
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
// 균등 분배 트리거
const [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0);
// 동적 데이터 소스 활성화 상태
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
// RepeaterColumnConfig로 변환
const columns: RepeaterColumnConfig[] = (tableConfig.columns || []).map(convertToRepeaterColumn);
// 계산 규칙 변환
const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule);
// 계산 로직
const calculateRow = useCallback(
(row: any): any => {
if (calculationRules.length === 0) return row;
const updatedRow = { ...row };
for (const rule of calculationRules) {
try {
let formula = rule.formula;
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
const 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());
}
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]
);
// 데이터 변경 핸들러
const handleDataChange = useCallback(
(newData: any[]) => {
setTableData(newData);
onTableDataChange(newData);
},
[onTableDataChange]
);
// 행 변경 핸들러
const handleRowChange = useCallback(
(index: number, newRow: any) => {
const calculatedRow = calculateRow(newRow);
const newData = [...tableData];
newData[index] = calculatedRow;
handleDataChange(newData);
},
[tableData, calculateRow, handleDataChange]
);
// 행 삭제 핸들러
const handleRowDelete = useCallback(
(index: number) => {
const newData = tableData.filter((_, i) => i !== index);
handleDataChange(newData);
},
[tableData, handleDataChange]
);
// 선택된 항목 일괄 삭제
const handleBulkDelete = useCallback(() => {
if (selectedRows.size === 0) return;
const newData = tableData.filter((_, index) => !selectedRows.has(index));
handleDataChange(newData);
setSelectedRows(new Set());
}, [tableData, selectedRows, handleDataChange]);
// 아이템 추가 핸들러 (모달에서 선택)
const handleAddItems = useCallback(
async (items: any[]) => {
// 각 아이템에 대해 valueMapping 적용
const mappedItems = await Promise.all(
items.map(async (sourceItem) => {
const newItem: any = {};
for (const col of tableConfig.columns) {
const mapping = col.valueMapping;
// 1. 먼저 col.sourceField 확인 (간단 매핑)
if (!mapping && col.sourceField) {
// sourceField가 명시적으로 설정된 경우
if (sourceItem[col.sourceField] !== undefined) {
newItem[col.field] = sourceItem[col.sourceField];
}
continue;
}
if (!mapping) {
// 매핑 없으면 소스에서 동일 필드명으로 복사
if (sourceItem[col.field] !== undefined) {
newItem[col.field] = sourceItem[col.field];
}
continue;
}
// 2. valueMapping이 있는 경우 (고급 매핑)
switch (mapping.type) {
case "source":
// 소스 테이블에서 복사
const srcField = mapping.sourceField || col.sourceField || col.field;
if (sourceItem[srcField] !== undefined) {
newItem[col.field] = sourceItem[srcField];
}
break;
case "manual":
// 사용자 입력 (빈 값 또는 기본값)
newItem[col.field] = col.defaultValue ?? undefined;
break;
case "internal":
// formData에서 값 가져오기
if (mapping.internalField) {
newItem[col.field] = formData[mapping.internalField];
}
break;
case "external":
// 외부 테이블에서 조회
if (mapping.externalRef) {
const { tableName, valueColumn, joinConditions } = mapping.externalRef;
const value = await fetchExternalValue(
tableName,
valueColumn,
joinConditions,
{ ...sourceItem, ...newItem }, // 현재까지 빌드된 아이템
formData
);
if (value !== undefined) {
newItem[col.field] = value;
}
}
break;
}
// 기본값 적용
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
newItem[col.field] = col.defaultValue;
}
}
return newItem;
})
);
// 계산 필드 업데이트
const calculatedItems = calculateAll(mappedItems);
// 기존 데이터에 추가
const newData = [...tableData, ...calculatedItems];
handleDataChange(newData);
},
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange]
);
// 컬럼 모드 변경 핸들러
const handleDataSourceChange = useCallback(
async (columnField: string, optionId: string) => {
setActiveDataSources((prev) => ({
...prev,
[columnField]: optionId,
}));
// 해당 컬럼의 모든 행 데이터 재조회
const column = tableConfig.columns.find((col) => col.field === columnField);
if (!column?.columnModes) return;
const selectedMode = column.columnModes.find((mode) => mode.id === optionId);
if (!selectedMode) return;
// 모든 행에 대해 새 값 조회
const updatedData = await Promise.all(
tableData.map(async (row) => {
const mapping = selectedMode.valueMapping;
let newValue: any = row[columnField];
if (mapping.type === "external" && mapping.externalRef) {
const { tableName, valueColumn, joinConditions } = mapping.externalRef;
const value = await fetchExternalValue(tableName, valueColumn, joinConditions, row, formData);
if (value !== undefined) {
newValue = value;
}
} else if (mapping.type === "source" && mapping.sourceField) {
newValue = row[mapping.sourceField];
} else if (mapping.type === "internal" && mapping.internalField) {
newValue = formData[mapping.internalField];
}
return { ...row, [columnField]: newValue };
})
);
// 계산 필드 업데이트
const calculatedData = calculateAll(updatedData);
handleDataChange(calculatedData);
},
[tableConfig.columns, tableData, formData, calculateAll, handleDataChange]
);
// 소스 테이블 정보
const { source, filters, uiConfig } = tableConfig;
const sourceTable = source.tableName;
const sourceColumns = source.displayColumns;
const sourceSearchFields = source.searchColumns;
const columnLabels = source.columnLabels || {};
const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택";
const addButtonText = uiConfig?.addButtonText || "항목 검색";
const multiSelect = uiConfig?.multiSelect ?? true;
// 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리)
const baseFilterCondition: Record<string, any> = {};
if (filters?.preFilters) {
for (const filter of filters.preFilters) {
// 간단한 "=" 연산자만 처리 (확장 가능)
if (filter.operator === "=") {
baseFilterCondition[filter.column] = filter.value;
}
}
}
// 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환
const modalFiltersForModal = useMemo(() => {
if (!filters?.modalFilters) return [];
return filters.modalFilters.map((filter) => ({
column: filter.column,
label: filter.label || filter.column,
type: filter.type,
options: filter.options,
categoryRef: filter.categoryRef,
booleanRef: filter.booleanRef,
defaultValue: filter.defaultValue,
}));
}, [filters?.modalFilters]);
return (
<div className={cn("space-y-4", className)}>
{/* 추가 버튼 영역 */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{tableData.length > 0 && `${tableData.length}개 항목`}
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
</span>
{columns.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setEqualizeWidthsTrigger((prev) => prev + 1)}
className="h-7 text-xs px-2"
title="컬럼 너비 균등 분배"
>
<Columns className="h-3.5 w-3.5 mr-1" />
</Button>
)}
</div>
<div className="flex gap-2">
{selectedRows.size > 0 && (
<Button
variant="destructive"
onClick={handleBulkDelete}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
({selectedRows.size})
</Button>
)}
<Button
onClick={() => setModalOpen(true)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="h-4 w-4 mr-2" />
{addButtonText}
</Button>
</div>
</div>
{/* Repeater 테이블 */}
<RepeaterTable
columns={columns}
data={tableData}
onDataChange={handleDataChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
activeDataSources={activeDataSources}
onDataSourceChange={handleDataSourceChange}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={equalizeWidthsTrigger}
/>
{/* 항목 선택 모달 */}
<ItemSelectionModal
open={modalOpen}
onOpenChange={setModalOpen}
sourceTable={sourceTable}
sourceColumns={sourceColumns}
sourceSearchFields={sourceSearchFields}
multiSelect={multiSelect}
filterCondition={baseFilterCondition}
modalTitle={modalTitle}
alreadySelected={tableData}
uniqueField={tableConfig.saveConfig?.uniqueField}
onSelect={handleAddItems}
columnLabels={columnLabels}
modalFilters={modalFiltersForModal}
/>
</div>
);
}

View File

@ -38,6 +38,7 @@ import {
OptionalFieldGroupConfig,
} from "./types";
import { defaultConfig, generateUniqueId } from "./config";
import { TableSectionRenderer } from "./TableSectionRenderer";
/**
* 🔗 Select
@ -269,7 +270,7 @@ export function UniversalFormModalComponent({
// 설정에 정의된 필드 columnName 목록 수집
const configuredFields = new Set<string>();
config.sections.forEach((section) => {
section.fields.forEach((field) => {
(section.fields || []).forEach((field) => {
if (field.columnName) {
configuredFields.add(field.columnName);
}
@ -319,7 +320,7 @@ export function UniversalFormModalComponent({
// 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집
config.sections.forEach((section) => {
section.fields.forEach((field) => {
(section.fields || []).forEach((field) => {
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) {
tablesToLoad.add(field.linkedFieldGroup.sourceTable);
}
@ -372,9 +373,12 @@ export function UniversalFormModalComponent({
items.push(createRepeatItem(section, i));
}
newRepeatSections[section.id] = items;
} else if (section.type === "table") {
// 테이블 섹션은 필드 초기화 스킵 (TableSectionRenderer에서 처리)
continue;
} else {
// 일반 섹션 필드 초기화
for (const field of section.fields) {
for (const field of (section.fields || [])) {
// 기본값 설정
let value = field.defaultValue ?? "";
@ -448,7 +452,7 @@ export function UniversalFormModalComponent({
_index: index,
};
for (const field of section.fields) {
for (const field of (section.fields || [])) {
item[field.columnName] = field.defaultValue ?? "";
}
@ -479,9 +483,9 @@ export function UniversalFormModalComponent({
let hasChanges = false;
for (const section of config.sections) {
if (section.repeatable) continue;
if (section.repeatable || section.type === "table") continue;
for (const field of section.fields) {
for (const field of (section.fields || [])) {
if (
field.numberingRule?.enabled &&
field.numberingRule?.generateOnOpen &&
@ -781,9 +785,9 @@ export function UniversalFormModalComponent({
const missingFields: string[] = [];
for (const section of config.sections) {
if (section.repeatable) continue; // 반복 섹션은 별도 검증
if (section.repeatable || section.type === "table") continue; // 반복 섹션 및 테이블 섹션은 별도 검증
for (const field of section.fields) {
for (const field of (section.fields || [])) {
if (field.required && !field.hidden && !field.numberingRule?.hidden) {
const value = formData[field.columnName];
if (value === undefined || value === null || value === "") {
@ -799,17 +803,28 @@ export function UniversalFormModalComponent({
// 단일 행 저장
const saveSingleRow = useCallback(async () => {
const dataToSave = { ...formData };
// 테이블 섹션 데이터 추출 (별도 저장용)
const tableSectionData: Record<string, any[]> = {};
// 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용)
Object.keys(dataToSave).forEach((key) => {
if (key.startsWith("_") && !key.includes("_numberingRuleId")) {
if (key.startsWith("_tableSection_")) {
// 테이블 섹션 데이터는 별도로 저장
const sectionId = key.replace("_tableSection_", "");
tableSectionData[sectionId] = dataToSave[key] || [];
delete dataToSave[key];
} else if (key.startsWith("_") && !key.includes("_numberingRuleId")) {
delete dataToSave[key];
}
});
// 저장 시점 채번규칙 처리 (generateOnSave만 처리)
for (const section of config.sections) {
for (const field of section.fields) {
// 테이블 타입 섹션은 건너뛰기
if (section.type === "table") continue;
for (const field of (section.fields || [])) {
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
@ -822,12 +837,37 @@ export function UniversalFormModalComponent({
}
}
// 메인 데이터 저장
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave);
if (!response.data?.success) {
throw new Error(response.data?.message || "저장 실패");
}
}, [config.sections, config.saveConfig.tableName, formData]);
// 테이블 섹션 데이터 저장 (별도 테이블에)
for (const section of config.sections) {
if (section.type === "table" && section.tableConfig?.saveConfig?.targetTable) {
const sectionData = tableSectionData[section.id];
if (sectionData && sectionData.length > 0) {
// 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
const mainRecordId = response.data?.data?.id;
for (const item of sectionData) {
const itemToSave = { ...item };
// 메인 레코드와 연결이 필요한 경우
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
}
await apiClient.post(
`/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`,
itemToSave
);
}
}
}
}
}, [config.sections, config.saveConfig.tableName, config.saveConfig.primaryKeyColumn, formData]);
// 다중 행 저장 (겸직 등)
const saveMultipleRows = useCallback(async () => {
@ -901,9 +941,9 @@ export function UniversalFormModalComponent({
// 저장 시점 채번규칙 처리 (메인 행만)
for (const section of config.sections) {
if (section.repeatable) continue;
if (section.repeatable || section.type === "table") continue;
for (const field of section.fields) {
for (const field of (section.fields || [])) {
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
// generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
@ -951,8 +991,8 @@ export function UniversalFormModalComponent({
// 1. 메인 테이블 데이터 구성
const mainData: Record<string, any> = {};
config.sections.forEach((section) => {
if (section.repeatable) return; // 반복 섹션 제외
section.fields.forEach((field) => {
if (section.repeatable || section.type === "table") return; // 반복 섹션 및 테이블 타입 제외
(section.fields || []).forEach((field) => {
const value = formData[field.columnName];
if (value !== undefined && value !== null && value !== "") {
mainData[field.columnName] = value;
@ -962,9 +1002,9 @@ export function UniversalFormModalComponent({
// 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
for (const section of config.sections) {
if (section.repeatable) continue;
if (section.repeatable || section.type === "table") continue;
for (const field of section.fields) {
for (const field of (section.fields || [])) {
// 채번규칙이 활성화된 필드 처리
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
// 신규 생성이거나 값이 없는 경우에만 채번
@ -1054,8 +1094,8 @@ export function UniversalFormModalComponent({
// 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑
else {
config.sections.forEach((section) => {
if (section.repeatable) return;
const matchingField = section.fields.find((f) => f.columnName === mapping.targetColumn);
if (section.repeatable || section.type === "table") return;
const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn);
if (matchingField && mainData[matchingField.columnName] !== undefined) {
mainFieldMappings!.push({
formField: matchingField.columnName,
@ -1535,10 +1575,36 @@ export function UniversalFormModalComponent({
const isCollapsed = collapsedSections.has(section.id);
const sectionColumns = section.columns || 2;
// 반복 섹션
if (section.repeatable) {
return renderRepeatableSection(section, isCollapsed);
}
// 테이블 타입 섹션
if (section.type === "table" && section.tableConfig) {
return (
<Card key={section.id} className="mb-4">
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description && <CardDescription className="text-xs">{section.description}</CardDescription>}
</CardHeader>
<CardContent>
<TableSectionRenderer
sectionId={section.id}
tableConfig={section.tableConfig}
formData={formData}
onFormDataChange={handleFieldChange}
onTableDataChange={(data) => {
// 테이블 섹션 데이터를 formData에 저장
handleFieldChange(`_tableSection_${section.id}`, data);
}}
/>
</CardContent>
</Card>
);
}
// 기본 필드 타입 섹션
return (
<Card key={section.id} className="mb-4">
{section.collapsible ? (
@ -1560,7 +1626,7 @@ export function UniversalFormModalComponent({
<CardContent>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{/* 일반 필드 렌더링 */}
{section.fields.map((field) =>
{(section.fields || []).map((field) =>
renderFieldWithColumns(
field,
formData[field.columnName],
@ -1582,7 +1648,7 @@ export function UniversalFormModalComponent({
<CardContent>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{/* 일반 필드 렌더링 */}
{section.fields.map((field) =>
{(section.fields || []).map((field) =>
renderFieldWithColumns(
field,
formData[field.columnName],
@ -1819,7 +1885,7 @@ export function UniversalFormModalComponent({
</div>
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
{/* 일반 필드 렌더링 */}
{section.fields.map((field) =>
{(section.fields || []).map((field) =>
renderFieldWithColumns(
field,
item[field.columnName],
@ -1898,7 +1964,7 @@ export function UniversalFormModalComponent({
<div className="text-muted-foreground text-center">
<p className="font-medium">{config.modal.title || "범용 폼 모달"}</p>
<p className="mt-1 text-xs">
{config.sections.length} |{config.sections.reduce((acc, s) => acc + s.fields.length, 0)}
{config.sections.length} |{config.sections.reduce((acc, s) => acc + (s.fields?.length || 0), 0)}
</p>
<p className="mt-1 text-xs"> : {config.saveConfig.tableName || "(미설정)"}</p>
</div>

View File

@ -17,6 +17,7 @@ import {
Settings,
Database,
Layout,
Table,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@ -27,9 +28,11 @@ import {
FormSectionConfig,
FormFieldConfig,
MODAL_SIZE_OPTIONS,
SECTION_TYPE_OPTIONS,
} from "./types";
import {
defaultSectionConfig,
defaultTableSectionConfig,
generateSectionId,
} from "./config";
@ -37,6 +40,7 @@ import {
import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
import { SaveSettingsModal } from "./modals/SaveSettingsModal";
import { SectionLayoutModal } from "./modals/SectionLayoutModal";
import { TableSectionSettingsModal } from "./modals/TableSectionSettingsModal";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
@ -57,6 +61,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
const [saveSettingsModalOpen, setSaveSettingsModalOpen] = useState(false);
const [sectionLayoutModalOpen, setSectionLayoutModalOpen] = useState(false);
const [fieldDetailModalOpen, setFieldDetailModalOpen] = useState(false);
const [tableSectionSettingsModalOpen, setTableSectionSettingsModalOpen] = useState(false);
const [selectedSection, setSelectedSection] = useState<FormSectionConfig | null>(null);
const [selectedField, setSelectedField] = useState<FormFieldConfig | null>(null);
@ -95,23 +100,25 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const data = response.data?.data;
// API 응답 구조: { success, data: { columns: [...], total, page, ... } }
const columns = response.data?.data?.columns;
if (response.data?.success && Array.isArray(data)) {
if (response.data?.success && Array.isArray(columns)) {
setTableColumns((prev) => ({
...prev,
[tableName]: data.map(
[tableName]: columns.map(
(c: {
columnName?: string;
column_name?: string;
dataType?: string;
data_type?: string;
displayName?: string;
columnComment?: string;
column_comment?: string;
}) => ({
name: c.columnName || c.column_name || "",
type: c.dataType || c.data_type || "text",
label: c.columnComment || c.column_comment || c.columnName || c.column_name || "",
label: c.displayName || c.columnComment || c.column_comment || c.columnName || c.column_name || "",
}),
),
}));
@ -159,17 +166,55 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
);
// 섹션 관리
const addSection = useCallback(() => {
const addSection = useCallback((type: "fields" | "table" = "fields") => {
const newSection: FormSectionConfig = {
...defaultSectionConfig,
id: generateSectionId(),
title: `섹션 ${config.sections.length + 1}`,
title: type === "table" ? `테이블 섹션 ${config.sections.length + 1}` : `섹션 ${config.sections.length + 1}`,
type,
fields: type === "fields" ? [] : undefined,
tableConfig: type === "table" ? { ...defaultTableSectionConfig } : undefined,
};
onChange({
...config,
sections: [...config.sections, newSection],
});
}, [config, onChange]);
// 섹션 타입 변경
const changeSectionType = useCallback(
(sectionId: string, newType: "fields" | "table") => {
onChange({
...config,
sections: config.sections.map((s) => {
if (s.id !== sectionId) return s;
if (newType === "table") {
return {
...s,
type: "table",
fields: undefined,
tableConfig: { ...defaultTableSectionConfig },
};
} else {
return {
...s,
type: "fields",
fields: [],
tableConfig: undefined,
};
}
}),
});
},
[config, onChange]
);
// 테이블 섹션 설정 모달 열기
const handleOpenTableSectionSettings = (section: FormSectionConfig) => {
setSelectedSection(section);
setTableSectionSettingsModalOpen(true);
};
const updateSection = useCallback(
(sectionId: string, updates: Partial<FormSectionConfig>) => {
@ -365,39 +410,56 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 space-y-4 w-full min-w-0">
<Button size="sm" variant="outline" onClick={addSection} className="h-9 text-xs w-full max-w-full">
<Plus className="h-4 w-4 mr-2" />
</Button>
{/* 섹션 추가 버튼들 */}
<div className="flex gap-2 w-full min-w-0">
<Button size="sm" variant="outline" onClick={() => addSection("fields")} className="h-9 text-xs flex-1 min-w-0">
<Plus className="h-4 w-4 mr-1 shrink-0" />
<span className="truncate"> </span>
</Button>
<Button size="sm" variant="outline" onClick={() => addSection("table")} className="h-9 text-xs flex-1 min-w-0">
<Table className="h-4 w-4 mr-1 shrink-0" />
<span className="truncate"> </span>
</Button>
</div>
<HelpText>
.
섹션: 일반 .
<br />
: 기본 , ,
섹션: 품목 .
</HelpText>
{config.sections.length === 0 ? (
<div className="text-center py-12 border border-dashed rounded-lg w-full bg-muted/20">
<p className="text-sm text-muted-foreground mb-2 font-medium"> </p>
<p className="text-xs text-muted-foreground">"섹션 추가" </p>
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-3 w-full min-w-0">
{config.sections.map((section, index) => (
<div key={section.id} className="border rounded-lg p-3 bg-card w-full min-w-0 overflow-hidden space-y-3">
{/* 헤더: 제목 + 삭제 */}
{/* 헤더: 제목 + 타입 배지 + 삭제 */}
<div className="flex items-start justify-between gap-3 w-full min-w-0">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-sm font-medium truncate">{section.title}</span>
{section.repeatable && (
{section.type === "table" ? (
<Badge variant="outline" className="text-xs px-1.5 py-0.5 text-purple-600 bg-purple-50 border-purple-200">
</Badge>
) : section.repeatable ? (
<Badge variant="outline" className="text-xs px-1.5 py-0.5">
</Badge>
)}
) : null}
</div>
<Badge variant="secondary" className="text-xs px-2 py-0.5">
{section.fields.length}
</Badge>
{section.type === "table" ? (
<Badge variant="secondary" className="text-xs px-2 py-0.5">
{section.tableConfig?.source?.tableName || "(소스 미설정)"}
</Badge>
) : (
<Badge variant="secondary" className="text-xs px-2 py-0.5">
{(section.fields || []).length}
</Badge>
)}
</div>
<Button
@ -435,10 +497,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
</div>
</div>
{/* 필드 목록 */}
{section.fields.length > 0 && (
{/* 필드 목록 (필드 타입만) */}
{section.type !== "table" && (section.fields || []).length > 0 && (
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
{section.fields.slice(0, 4).map((field) => (
{(section.fields || []).slice(0, 4).map((field) => (
<Badge
key={field.id}
variant="outline"
@ -447,24 +509,56 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
{field.label}
</Badge>
))}
{section.fields.length > 4 && (
{(section.fields || []).length > 4 && (
<Badge variant="outline" className="text-xs px-2 py-0.5 shrink-0">
+{section.fields.length - 4}
+{(section.fields || []).length - 4}
</Badge>
)}
</div>
)}
{/* 테이블 컬럼 목록 (테이블 타입만) */}
{section.type === "table" && section.tableConfig?.columns && section.tableConfig.columns.length > 0 && (
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
{section.tableConfig.columns.slice(0, 4).map((col) => (
<Badge
key={col.field}
variant="outline"
className="text-xs px-2 py-0.5 shrink-0 text-purple-600 bg-purple-50 border-purple-200"
>
{col.label}
</Badge>
))}
{section.tableConfig.columns.length > 4 && (
<Badge variant="outline" className="text-xs px-2 py-0.5 shrink-0">
+{section.tableConfig.columns.length - 4}
</Badge>
)}
</div>
)}
{/* 레이아웃 설정 버튼 */}
<Button
size="sm"
variant="outline"
onClick={() => handleOpenSectionLayout(section)}
className="h-9 text-xs w-full"
>
<Layout className="h-4 w-4 mr-2" />
</Button>
{/* 설정 버튼 (타입에 따라 다름) */}
{section.type === "table" ? (
<Button
size="sm"
variant="outline"
onClick={() => handleOpenTableSectionSettings(section)}
className="h-9 text-xs w-full"
>
<Table className="h-4 w-4 mr-2" />
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => handleOpenSectionLayout(section)}
className="h-9 text-xs w-full"
>
<Layout className="h-4 w-4 mr-2" />
</Button>
)}
</div>
))}
</div>
@ -530,7 +624,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
const updatedSection = {
...selectedSection,
// 기본 필드 목록에서 업데이트
fields: selectedSection.fields.map((f) => (f.id === updatedField.id ? updatedField : f)),
fields: (selectedSection.fields || []).map((f) => (f.id === updatedField.id ? updatedField : f)),
// 옵셔널 필드 그룹 내 필드도 업데이트
optionalFieldGroups: selectedSection.optionalFieldGroups?.map((group) => ({
...group,
@ -558,6 +652,45 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
onLoadTableColumns={loadTableColumns}
/>
)}
{/* 테이블 섹션 설정 모달 */}
{selectedSection && selectedSection.type === "table" && (
<TableSectionSettingsModal
open={tableSectionSettingsModalOpen}
onOpenChange={setTableSectionSettingsModalOpen}
section={selectedSection}
onSave={(updates) => {
const updatedSection = {
...selectedSection,
...updates,
};
// config 업데이트
onChange({
...config,
sections: config.sections.map((s) =>
s.id === selectedSection.id ? updatedSection : s
),
});
setSelectedSection(updatedSection);
setTableSectionSettingsModalOpen(false);
}}
tables={tables.map(t => ({ table_name: t.name, comment: t.label }))}
tableColumns={Object.fromEntries(
Object.entries(tableColumns).map(([tableName, cols]) => [
tableName,
cols.map(c => ({
column_name: c.name,
data_type: c.type,
is_nullable: "YES",
comment: c.label,
})),
])
)}
onLoadTableColumns={loadTableColumns}
/>
)}
</div>
);
}

View File

@ -2,7 +2,16 @@
*
*/
import { UniversalFormModalConfig } from "./types";
import {
UniversalFormModalConfig,
TableSectionConfig,
TableColumnConfig,
ValueMappingConfig,
ColumnModeConfig,
TablePreFilter,
TableModalFilter,
TableCalculationRule,
} from "./types";
// 기본 설정값
export const defaultConfig: UniversalFormModalConfig = {
@ -77,6 +86,7 @@ export const defaultSectionConfig = {
id: "",
title: "새 섹션",
description: "",
type: "fields" as const,
collapsible: false,
defaultCollapsed: false,
columns: 2,
@ -95,6 +105,97 @@ export const defaultSectionConfig = {
linkedFieldGroups: [],
};
// ============================================
// 테이블 섹션 관련 기본값
// ============================================
// 기본 테이블 섹션 설정
export const defaultTableSectionConfig: TableSectionConfig = {
source: {
tableName: "",
displayColumns: [],
searchColumns: [],
columnLabels: {},
},
filters: {
preFilters: [],
modalFilters: [],
},
columns: [],
calculations: [],
saveConfig: {
targetTable: undefined,
uniqueField: undefined,
},
uiConfig: {
addButtonText: "항목 검색",
modalTitle: "항목 검색 및 선택",
multiSelect: true,
maxHeight: "400px",
},
};
// 기본 테이블 컬럼 설정
export const defaultTableColumnConfig: TableColumnConfig = {
field: "",
label: "",
type: "text",
editable: true,
calculated: false,
required: false,
width: "150px",
minWidth: "60px",
maxWidth: "400px",
defaultValue: undefined,
selectOptions: [],
valueMapping: undefined,
columnModes: [],
};
// 기본 값 매핑 설정
export const defaultValueMappingConfig: ValueMappingConfig = {
type: "source",
sourceField: "",
externalRef: undefined,
internalField: undefined,
};
// 기본 컬럼 모드 설정
export const defaultColumnModeConfig: ColumnModeConfig = {
id: "",
label: "",
isDefault: false,
valueMapping: {
type: "source",
sourceField: "",
},
};
// 기본 사전 필터 설정
export const defaultPreFilterConfig: TablePreFilter = {
column: "",
operator: "=",
value: "",
};
// 기본 모달 필터 설정
export const defaultModalFilterConfig: TableModalFilter = {
column: "",
label: "",
type: "category",
categoryRef: undefined,
options: [],
optionsFromTable: undefined,
defaultValue: undefined,
};
// 기본 계산 규칙 설정
export const defaultCalculationRuleConfig: TableCalculationRule = {
resultField: "",
formula: "",
dependencies: [],
};
// 기본 옵셔널 필드 그룹 설정
export const defaultOptionalFieldGroupConfig = {
id: "",
@ -184,3 +285,18 @@ export const generateFieldId = (): string => {
export const generateLinkedFieldGroupId = (): string => {
return generateUniqueId("linked");
};
// 유틸리티: 테이블 컬럼 ID 생성
export const generateTableColumnId = (): string => {
return generateUniqueId("tcol");
};
// 유틸리티: 컬럼 모드 ID 생성
export const generateColumnModeId = (): string => {
return generateUniqueId("mode");
};
// 유틸리티: 필터 ID 생성
export const generateFilterId = (): string => {
return generateUniqueId("filter");
};

View File

@ -219,13 +219,16 @@ export function SaveSettingsModal({
const getAllFields = (): { columnName: string; label: string; sectionTitle: string }[] => {
const fields: { columnName: string; label: string; sectionTitle: string }[] = [];
sections.forEach((section) => {
section.fields.forEach((field) => {
fields.push({
columnName: field.columnName,
label: field.label,
sectionTitle: section.title,
// 필드 타입 섹션만 처리 (테이블 타입은 fields가 undefined)
if (section.fields && Array.isArray(section.fields)) {
section.fields.forEach((field) => {
fields.push({
columnName: field.columnName,
label: field.label,
sectionTitle: section.title,
});
});
});
}
});
return fields;
};

View File

@ -37,13 +37,19 @@ export function SectionLayoutModal({
onOpenFieldDetail,
}: SectionLayoutModalProps) {
// 로컬 상태로 섹션 관리
const [localSection, setLocalSection] = useState<FormSectionConfig>(section);
// 로컬 상태로 섹션 관리 (fields가 없으면 빈 배열로 초기화)
const [localSection, setLocalSection] = useState<FormSectionConfig>(() => ({
...section,
fields: section.fields || [],
}));
// open이 변경될 때마다 데이터 동기화
useEffect(() => {
if (open) {
setLocalSection(section);
setLocalSection({
...section,
fields: section.fields || [],
});
}
}, [open, section]);
@ -59,42 +65,45 @@ export function SectionLayoutModal({
onOpenChange(false);
};
// fields 배열 (안전한 접근)
const fields = localSection.fields || [];
// 필드 추가
const addField = () => {
const newField: FormFieldConfig = {
...defaultFieldConfig,
id: generateFieldId(),
label: `새 필드 ${localSection.fields.length + 1}`,
columnName: `field_${localSection.fields.length + 1}`,
label: `새 필드 ${fields.length + 1}`,
columnName: `field_${fields.length + 1}`,
};
updateSection({
fields: [...localSection.fields, newField],
fields: [...fields, newField],
});
};
// 필드 삭제
const removeField = (fieldId: string) => {
updateSection({
fields: localSection.fields.filter((f) => f.id !== fieldId),
fields: fields.filter((f) => f.id !== fieldId),
});
};
// 필드 업데이트
const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
updateSection({
fields: localSection.fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)),
fields: fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)),
});
};
// 필드 이동
const moveField = (fieldId: string, direction: "up" | "down") => {
const index = localSection.fields.findIndex((f) => f.id === fieldId);
const index = fields.findIndex((f) => f.id === fieldId);
if (index === -1) return;
if (direction === "up" && index === 0) return;
if (direction === "down" && index === localSection.fields.length - 1) return;
if (direction === "down" && index === fields.length - 1) return;
const newFields = [...localSection.fields];
const newFields = [...fields];
const targetIndex = direction === "up" ? index - 1 : index + 1;
[newFields[index], newFields[targetIndex]] = [newFields[targetIndex], newFields[index]];
@ -317,7 +326,7 @@ export function SectionLayoutModal({
<div className="flex items-center gap-2">
<h3 className="text-xs font-semibold"> </h3>
<Badge variant="secondary" className="text-[9px] px-1.5 py-0">
{localSection.fields.length}
{fields.length}
</Badge>
</div>
<Button size="sm" variant="outline" onClick={addField} className="h-7 text-[10px] px-2">
@ -330,14 +339,14 @@ export function SectionLayoutModal({
. "상세 설정" .
</HelpText>
{localSection.fields.length === 0 ? (
{fields.length === 0 ? (
<div className="text-center py-8 border border-dashed rounded-lg">
<p className="text-sm text-muted-foreground mb-2"> </p>
<p className="text-xs text-muted-foreground"> "필드 추가" </p>
</div>
) : (
<div className="space-y-2">
{localSection.fields.map((field, index) => (
{fields.map((field, index) => (
<div
key={field.id}
className={cn(
@ -363,7 +372,7 @@ export function SectionLayoutModal({
size="sm"
variant="ghost"
onClick={() => moveField(field.id, "down")}
disabled={index === localSection.fields.length - 1}
disabled={index === fields.length - 1}
className="h-3 w-5 p-0"
>
<ChevronDown className="h-2.5 w-2.5" />
@ -929,7 +938,7 @@ export function SectionLayoutModal({
</Button>
<Button onClick={handleSave} className="h-9 text-sm">
({localSection.fields.length} )
({fields.length} )
</Button>
</DialogFooter>
</DialogContent>

View File

@ -0,0 +1,769 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
// 타입 import
import {
TableColumnConfig,
ValueMappingConfig,
ColumnModeConfig,
TableJoinCondition,
VALUE_MAPPING_TYPE_OPTIONS,
JOIN_SOURCE_TYPE_OPTIONS,
TABLE_COLUMN_TYPE_OPTIONS,
} from "../types";
import {
defaultValueMappingConfig,
defaultColumnModeConfig,
generateColumnModeId,
} from "../config";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
);
interface TableColumnSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
column: TableColumnConfig;
sourceTableColumns: { column_name: string; data_type: string; comment?: string }[];
formFields: { columnName: string; label: string }[]; // formData 필드 목록
onSave: (updatedColumn: TableColumnConfig) => void;
tables: { table_name: string; comment?: string }[];
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>;
onLoadTableColumns: (tableName: string) => void;
}
export function TableColumnSettingsModal({
open,
onOpenChange,
column,
sourceTableColumns,
formFields,
onSave,
tables,
tableColumns,
onLoadTableColumns,
}: TableColumnSettingsModalProps) {
// 로컬 상태
const [localColumn, setLocalColumn] = useState<TableColumnConfig>({ ...column });
// 외부 테이블 검색 상태
const [externalTableOpen, setExternalTableOpen] = useState(false);
// 활성 탭
const [activeTab, setActiveTab] = useState("basic");
// open이 변경될 때마다 데이터 동기화
useEffect(() => {
if (open) {
setLocalColumn({ ...column });
}
}, [open, column]);
// 외부 테이블 컬럼 로드
const externalTableName = localColumn.valueMapping?.externalRef?.tableName;
useEffect(() => {
if (externalTableName) {
onLoadTableColumns(externalTableName);
}
}, [externalTableName, onLoadTableColumns]);
// 외부 테이블의 컬럼 목록
const externalTableColumns = useMemo(() => {
if (!externalTableName) return [];
return tableColumns[externalTableName] || [];
}, [tableColumns, externalTableName]);
// 컬럼 업데이트 함수
const updateColumn = (updates: Partial<TableColumnConfig>) => {
setLocalColumn((prev) => ({ ...prev, ...updates }));
};
// 값 매핑 업데이트
const updateValueMapping = (updates: Partial<ValueMappingConfig>) => {
const current = localColumn.valueMapping || { ...defaultValueMappingConfig };
updateColumn({
valueMapping: { ...current, ...updates },
});
};
// 외부 참조 업데이트
const updateExternalRef = (updates: Partial<NonNullable<ValueMappingConfig["externalRef"]>>) => {
const current = localColumn.valueMapping?.externalRef || {
tableName: "",
valueColumn: "",
joinConditions: [],
};
updateValueMapping({
externalRef: { ...current, ...updates },
});
};
// 조인 조건 추가
const addJoinCondition = () => {
const current = localColumn.valueMapping?.externalRef?.joinConditions || [];
const newCondition: TableJoinCondition = {
sourceType: "row",
sourceField: "",
targetColumn: "",
operator: "=",
};
updateExternalRef({
joinConditions: [...current, newCondition],
});
};
// 조인 조건 삭제
const removeJoinCondition = (index: number) => {
const current = localColumn.valueMapping?.externalRef?.joinConditions || [];
updateExternalRef({
joinConditions: current.filter((_, i) => i !== index),
});
};
// 조인 조건 업데이트
const updateJoinCondition = (index: number, updates: Partial<TableJoinCondition>) => {
const current = localColumn.valueMapping?.externalRef?.joinConditions || [];
updateExternalRef({
joinConditions: current.map((c, i) => (i === index ? { ...c, ...updates } : c)),
});
};
// 컬럼 모드 추가
const addColumnMode = () => {
const newMode: ColumnModeConfig = {
...defaultColumnModeConfig,
id: generateColumnModeId(),
label: `모드 ${(localColumn.columnModes || []).length + 1}`,
};
updateColumn({
columnModes: [...(localColumn.columnModes || []), newMode],
});
};
// 컬럼 모드 삭제
const removeColumnMode = (index: number) => {
updateColumn({
columnModes: (localColumn.columnModes || []).filter((_, i) => i !== index),
});
};
// 컬럼 모드 업데이트
const updateColumnMode = (index: number, updates: Partial<ColumnModeConfig>) => {
updateColumn({
columnModes: (localColumn.columnModes || []).map((m, i) =>
i === index ? { ...m, ...updates } : m
),
});
};
// 저장 함수
const handleSave = () => {
onSave(localColumn);
onOpenChange(false);
};
// 값 매핑 타입에 따른 설정 UI 렌더링
const renderValueMappingConfig = () => {
const mappingType = localColumn.valueMapping?.type || "source";
switch (mappingType) {
case "source":
return (
<div className="space-y-3">
<div>
<Label className="text-xs"> </Label>
<Select
value={localColumn.valueMapping?.sourceField || ""}
onValueChange={(value) => updateValueMapping({ sourceField: value })}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue placeholder="컬럼 선택..." />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
{col.comment && ` (${col.comment})`}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> .</HelpText>
</div>
</div>
);
case "manual":
return (
<div className="text-sm text-muted-foreground p-4 border rounded-lg bg-muted/20">
.
<br />
"기본 설정" .
</div>
);
case "internal":
return (
<div className="space-y-3">
<div>
<Label className="text-xs"> </Label>
<Select
value={localColumn.valueMapping?.internalField || ""}
onValueChange={(value) => updateValueMapping({ internalField: value })}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue placeholder="필드 선택..." />
</SelectTrigger>
<SelectContent>
{formFields.map((field) => (
<SelectItem key={field.columnName} value={field.columnName}>
{field.label} ({field.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> .</HelpText>
</div>
</div>
);
case "external":
return (
<div className="space-y-4">
{/* 외부 테이블 선택 */}
<div>
<Label className="text-xs"> </Label>
<Popover open={externalTableOpen} onOpenChange={setExternalTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs mt-1"
>
{externalTableName || "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-full min-w-[300px]" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="text-xs py-4 text-center">
.
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
updateExternalRef({ tableName: table.table_name });
setExternalTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3.5 w-3.5",
externalTableName === table.table_name ? "opacity-100" : "opacity-0"
)}
/>
{table.table_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 가져올 컬럼 선택 */}
{externalTableName && (
<div>
<Label className="text-xs"> </Label>
<Select
value={localColumn.valueMapping?.externalRef?.valueColumn || ""}
onValueChange={(value) => updateExternalRef({ valueColumn: value })}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue placeholder="컬럼 선택..." />
</SelectTrigger>
<SelectContent>
{externalTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 조인 조건 */}
{externalTableName && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label className="text-xs"> </Label>
<Button size="sm" variant="outline" onClick={addJoinCondition} className="h-7 text-xs">
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{(localColumn.valueMapping?.externalRef?.joinConditions || []).map((condition, index) => (
<div key={index} className="flex items-center gap-2 p-2 border rounded-lg bg-muted/30">
{/* 소스 타입 */}
<Select
value={condition.sourceType}
onValueChange={(value: "row" | "formData") =>
updateJoinCondition(index, { sourceType: value })
}
>
<SelectTrigger className="h-7 text-xs w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{JOIN_SOURCE_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 소스 필드 */}
<Select
value={condition.sourceField}
onValueChange={(value) => updateJoinCondition(index, { sourceField: value })}
>
<SelectTrigger className="h-7 text-xs w-[120px]">
<SelectValue placeholder="필드" />
</SelectTrigger>
<SelectContent>
{condition.sourceType === "row"
? sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))
: formFields.map((field) => (
<SelectItem key={field.columnName} value={field.columnName}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
{/* 타겟 컬럼 */}
<Select
value={condition.targetColumn}
onValueChange={(value) => updateJoinCondition(index, { targetColumn: value })}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="대상 컬럼" />
</SelectTrigger>
<SelectContent>
{externalTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeJoinCondition(index)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{(localColumn.valueMapping?.externalRef?.joinConditions || []).length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
.
</p>
)}
</div>
)}
</div>
);
default:
return null;
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[700px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
"{localColumn.label}" .
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-[calc(90vh-200px)]">
<div className="space-y-4 p-1">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="w-full grid grid-cols-3">
<TabsTrigger value="basic" className="text-xs"> </TabsTrigger>
<TabsTrigger value="mapping" className="text-xs"> </TabsTrigger>
<TabsTrigger value="modes" className="text-xs"> </TabsTrigger>
</TabsList>
{/* 기본 설정 탭 */}
<TabsContent value="basic" className="mt-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={localColumn.field}
onChange={(e) => updateColumn({ field: e.target.value })}
placeholder="field_name"
className="h-8 text-xs mt-1"
/>
<HelpText> .</HelpText>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={localColumn.label}
onChange={(e) => updateColumn({ label: e.target.value })}
placeholder="표시 라벨"
className="h-8 text-xs mt-1"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label className="text-xs"></Label>
<Select
value={localColumn.type}
onValueChange={(value: any) => updateColumn({ type: value })}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TABLE_COLUMN_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={localColumn.width || ""}
onChange={(e) => updateColumn({ width: e.target.value })}
placeholder="150px"
className="h-8 text-xs mt-1"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={localColumn.defaultValue?.toString() || ""}
onChange={(e) => updateColumn({ defaultValue: e.target.value })}
placeholder="기본값"
className="h-8 text-xs mt-1"
/>
</div>
</div>
<Separator />
<div className="space-y-3">
<h4 className="text-sm font-medium"></h4>
<div className="grid grid-cols-3 gap-4">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch
checked={localColumn.editable ?? true}
onCheckedChange={(checked) => updateColumn({ editable: checked })}
className="scale-75"
/>
<span> </span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch
checked={localColumn.calculated ?? false}
onCheckedChange={(checked) => updateColumn({ calculated: checked })}
className="scale-75"
/>
<span> </span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch
checked={localColumn.required ?? false}
onCheckedChange={(checked) => updateColumn({ required: checked })}
className="scale-75"
/>
<span></span>
</label>
</div>
</div>
{/* Select 옵션 (타입이 select일 때) */}
{localColumn.type === "select" && (
<>
<Separator />
<div className="space-y-3">
<h4 className="text-sm font-medium">Select </h4>
<div className="space-y-2">
{(localColumn.selectOptions || []).map((opt, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={opt.value}
onChange={(e) => {
const newOptions = [...(localColumn.selectOptions || [])];
newOptions[index] = { ...newOptions[index], value: e.target.value };
updateColumn({ selectOptions: newOptions });
}}
placeholder="값"
className="h-8 text-xs flex-1"
/>
<Input
value={opt.label}
onChange={(e) => {
const newOptions = [...(localColumn.selectOptions || [])];
newOptions[index] = { ...newOptions[index], label: e.target.value };
updateColumn({ selectOptions: newOptions });
}}
placeholder="라벨"
className="h-8 text-xs flex-1"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
updateColumn({
selectOptions: (localColumn.selectOptions || []).filter((_, i) => i !== index),
});
}}
className="h-8 w-8 p-0 text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() => {
updateColumn({
selectOptions: [...(localColumn.selectOptions || []), { value: "", label: "" }],
});
}}
className="h-8 text-xs w-full"
>
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
</div>
</>
)}
</TabsContent>
{/* 값 매핑 탭 */}
<TabsContent value="mapping" className="mt-4 space-y-4">
<div>
<Label className="text-xs"> </Label>
<Select
value={localColumn.valueMapping?.type || "source"}
onValueChange={(value: any) => {
// 타입 변경 시 기본 설정 초기화
updateColumn({
valueMapping: {
type: value,
sourceField: value === "source" ? "" : undefined,
internalField: value === "internal" ? "" : undefined,
externalRef: value === "external" ? {
tableName: "",
valueColumn: "",
joinConditions: [],
} : undefined,
},
});
}}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VALUE_MAPPING_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> .</HelpText>
</div>
<Separator />
{renderValueMappingConfig()}
</TabsContent>
{/* 컬럼 모드 탭 */}
<TabsContent value="modes" className="mt-4 space-y-4">
<div className="flex justify-between items-center">
<div>
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
<Button size="sm" variant="outline" onClick={addColumnMode} className="h-8 text-xs">
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
{(localColumn.columnModes || []).length === 0 ? (
<div className="text-center py-8 border border-dashed rounded-lg bg-muted/20">
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground mt-1">
: 기준 /
</p>
</div>
) : (
<div className="space-y-3">
{(localColumn.columnModes || []).map((mode, index) => (
<div key={mode.id} className="border rounded-lg p-3 space-y-3 bg-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{mode.label || `모드 ${index + 1}`}</span>
{mode.isDefault && (
<Badge variant="secondary" className="text-xs"></Badge>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeColumnMode(index)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"> </Label>
<Input
value={mode.label}
onChange={(e) => updateColumnMode(index, { label: e.target.value })}
placeholder="예: 기준 단가"
className="h-8 text-xs mt-1"
/>
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch
checked={mode.isDefault ?? false}
onCheckedChange={(checked) => {
// 기본 모드는 하나만
if (checked) {
updateColumn({
columnModes: (localColumn.columnModes || []).map((m, i) => ({
...m,
isDefault: i === index,
})),
});
} else {
updateColumnMode(index, { isDefault: false });
}
}}
className="scale-75"
/>
<span> </span>
</label>
</div>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={mode.valueMapping?.type || "source"}
onValueChange={(value: any) => {
updateColumnMode(index, {
valueMapping: {
type: value,
sourceField: value === "source" ? "" : undefined,
internalField: value === "internal" ? "" : undefined,
externalRef: value === "external" ? {
tableName: "",
valueColumn: "",
joinConditions: [],
} : undefined,
},
});
}}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VALUE_MAPPING_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
</ScrollArea>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -184,7 +184,12 @@ export interface FormSectionConfig {
description?: string;
collapsible?: boolean; // 접을 수 있는지 (기본: false)
defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false)
fields: FormFieldConfig[];
// 섹션 타입: fields (기본) 또는 table (테이블 형식)
type?: "fields" | "table";
// type: "fields" 일 때 사용
fields?: FormFieldConfig[];
// 반복 섹션 (겸직 등)
repeatable?: boolean;
@ -199,6 +204,183 @@ export interface FormSectionConfig {
// 섹션 레이아웃
columns?: number; // 필드 배치 컬럼 수 (기본: 2)
gap?: string; // 필드 간 간격
// type: "table" 일 때 사용
tableConfig?: TableSectionConfig;
}
// ============================================
// 테이블 섹션 관련 타입 정의
// ============================================
/**
*
*
*/
export interface TableSectionConfig {
// 1. 소스 설정 (검색 모달에서 데이터를 가져올 테이블)
source: {
tableName: string; // 소스 테이블명 (예: item_info)
displayColumns: string[]; // 모달에 표시할 컬럼
searchColumns: string[]; // 검색 가능한 컬럼
columnLabels?: Record<string, string>; // 컬럼 라벨 (컬럼명 -> 표시 라벨)
};
// 2. 필터 설정
filters?: {
// 사전 필터 (항상 적용, 사용자에게 노출되지 않음)
preFilters?: TablePreFilter[];
// 모달 내 필터 UI (사용자가 선택 가능)
modalFilters?: TableModalFilter[];
};
// 3. 테이블 컬럼 설정
columns: TableColumnConfig[];
// 4. 계산 규칙
calculations?: TableCalculationRule[];
// 5. 저장 설정
saveConfig?: {
targetTable?: string; // 다른 테이블에 저장 시 (미지정 시 메인 테이블)
uniqueField?: string; // 중복 체크 필드
};
// 6. UI 설정
uiConfig?: {
addButtonText?: string; // 추가 버튼 텍스트 (기본: "품목 검색")
modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택")
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
maxHeight?: string; // 테이블 최대 높이 (기본: "400px")
};
}
/**
*
*
*/
export interface TablePreFilter {
column: string; // 필터할 컬럼
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like";
value: any; // 필터 값
}
/**
*
* UI
*/
export interface TableModalFilter {
column: string; // 필터할 컬럼
label: string; // 필터 라벨
type: "category" | "text"; // 필터 타입 (category: 드롭다운, text: 텍스트 입력)
// 카테고리 참조 (type: "category"일 때) - 테이블에서 컬럼의 distinct 값 조회
categoryRef?: {
tableName: string; // 테이블명 (예: "item_info")
columnName: string; // 컬럼명 (예: "division")
};
// 정적 옵션 (직접 입력한 경우)
options?: { value: string; label: string }[];
// 테이블에서 동적 로드 (테이블 컬럼 조회)
optionsFromTable?: {
tableName: string;
valueColumn: string;
labelColumn: string;
distinct?: boolean; // 중복 제거 (기본: true)
};
// 기본값
defaultValue?: any;
}
/**
*
*/
export interface TableColumnConfig {
field: string; // 필드명 (저장할 컬럼명)
label: string; // 컬럼 헤더 라벨
type: "text" | "number" | "date" | "select"; // 입력 타입
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)
// 편집 설정
editable?: boolean; // 편집 가능 여부 (기본: true)
calculated?: boolean; // 계산 필드 여부 (자동 읽기전용)
required?: boolean; // 필수 입력 여부
// 너비 설정
width?: string; // 기본 너비 (예: "150px")
minWidth?: string; // 최소 너비
maxWidth?: string; // 최대 너비
// 기본값
defaultValue?: any;
// Select 옵션 (type이 "select"일 때)
selectOptions?: { value: string; label: string }[];
// 값 매핑 (핵심 기능) - 고급 설정용
valueMapping?: ValueMappingConfig;
// 컬럼 모드 전환 (동적 데이터 소스)
columnModes?: ColumnModeConfig[];
}
/**
*
*
*/
export interface ValueMappingConfig {
type: "source" | "manual" | "external" | "internal";
// type: "source" - 소스 테이블에서 복사
sourceField?: string; // 소스 테이블의 컬럼명
// type: "external" - 외부 테이블 조회
externalRef?: {
tableName: string; // 조회할 테이블
valueColumn: string; // 가져올 컬럼
joinConditions: TableJoinCondition[];
};
// type: "internal" - formData의 다른 필드 값 직접 사용
internalField?: string; // formData의 필드명
}
/**
*
*
*/
export interface TableJoinCondition {
sourceType: "row" | "formData"; // 값 출처 (현재 행 또는 폼 데이터)
sourceField: string; // 출처의 필드명
targetColumn: string; // 조회 테이블의 컬럼
operator?: "=" | "!=" | ">" | "<" | ">=" | "<="; // 연산자 (기본: "=")
}
/**
*
*
*/
export interface ColumnModeConfig {
id: string; // 모드 고유 ID
label: string; // 모드 라벨 (예: "기준 단가", "거래처별 단가")
isDefault?: boolean; // 기본 모드 여부
valueMapping: ValueMappingConfig; // 이 모드의 값 매핑
}
/**
*
*
*/
export interface TableCalculationRule {
resultField: string; // 결과를 저장할 필드
formula: string; // 계산 공식 (예: "quantity * unit_price")
dependencies: string[]; // 의존하는 필드들
}
// 다중 행 저장 설정
@ -432,3 +614,54 @@ export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [
{ value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" },
{ value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" },
] as const;
// ============================================
// 테이블 섹션 관련 상수
// ============================================
// 섹션 타입 옵션
export const SECTION_TYPE_OPTIONS = [
{ value: "fields", label: "필드 타입" },
{ value: "table", label: "테이블 타입" },
] as const;
// 테이블 컬럼 타입 옵션
export const TABLE_COLUMN_TYPE_OPTIONS = [
{ value: "text", label: "텍스트" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "select", label: "선택(드롭다운)" },
] as const;
// 값 매핑 타입 옵션
export const VALUE_MAPPING_TYPE_OPTIONS = [
{ value: "source", label: "소스 테이블에서 복사" },
{ value: "manual", label: "사용자 직접 입력" },
{ value: "external", label: "외부 테이블 조회" },
{ value: "internal", label: "폼 데이터 참조" },
] as const;
// 조인 조건 소스 타입 옵션
export const JOIN_SOURCE_TYPE_OPTIONS = [
{ value: "row", label: "현재 행 데이터" },
{ value: "formData", label: "폼 필드 값" },
] as const;
// 필터 연산자 옵션
export const FILTER_OPERATOR_OPTIONS = [
{ value: "=", label: "같음 (=)" },
{ value: "!=", label: "다름 (!=)" },
{ value: ">", label: "큼 (>)" },
{ value: "<", label: "작음 (<)" },
{ value: ">=", label: "크거나 같음 (>=)" },
{ value: "<=", label: "작거나 같음 (<=)" },
{ value: "in", label: "포함 (IN)" },
{ value: "notIn", label: "미포함 (NOT IN)" },
{ value: "like", label: "유사 (LIKE)" },
] as const;
// 모달 필터 타입 옵션
export const MODAL_FILTER_TYPE_OPTIONS = [
{ value: "category", label: "테이블 조회" },
{ value: "text", label: "텍스트 입력" },
] as const;