feat(UniversalFormModal): 테이블 섹션 기능 추가
- FormSectionConfig에 type("fields"|"table") 및 tableConfig 필드 추가
- TableSectionRenderer, TableSectionSettingsModal 신규 컴포넌트 생성
- ItemSelectionModal에 모달 필터 기능 추가 (소스 테이블 distinct 값 조회)
- 설정 패널에서 테이블 섹션 추가/설정 UI 구현
This commit is contained in:
parent
8687c88f70
commit
1c6eb2ae61
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 반복 테이블 컬럼 관리 */}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue