Merge branch 'main' into feature/screen-management

This commit is contained in:
kjs 2025-12-19 17:41:55 +09:00
commit 43a6fb675f
17 changed files with 6312 additions and 368 deletions

View File

@ -976,6 +976,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
// 최상위 컴포넌트 또는 조건부 컨테이너 내부 화면에 universal-form-modal이 있는지 확인
const hasUniversalFormModal = screenData.components.some(
(c) => {
// 최상위에 universal-form-modal이 있는 경우
if (c.componentType === "universal-form-modal") return true;
// 조건부 컨테이너 내부에 universal-form-modal이 있는 경우
// (조건부 컨테이너가 있으면 내부 화면에서 universal-form-modal을 사용하는 것으로 가정)
if (c.componentType === "conditional-container") return true;
return false;
}
);
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
@ -1024,7 +1037,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
onSave={handleSave}
// 🆕 UniversalFormModal이 있으면 onSave 전달 안 함 (자체 저장 로직 사용)
// ModalRepeaterTable만 있으면 기존대로 onSave 전달 (호환성 유지)
onSave={hasUniversalFormModal ? undefined : handleSave}
isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupedDataProp}

View File

@ -150,46 +150,54 @@ export function ConditionalSectionViewer({
/* 실행 모드: 실제 화면 렌더링 */
<div className="w-full">
{/* 화면 크기만큼의 절대 위치 캔버스 */}
<div
className="relative mx-auto"
style={{
width: screenResolution?.width ? `${screenResolution.width}px` : "100%",
height: screenResolution?.height ? `${screenResolution.height}px` : "auto",
minHeight: "200px",
}}
>
{components.map((component) => {
const { position = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
{/* UniversalFormModal이 있으면 onSave 전달하지 않음 (자체 저장 로직 사용) */}
{(() => {
const hasUniversalFormModal = components.some(
(c) => c.componentType === "universal-form-modal"
);
return (
<div
className="relative mx-auto"
style={{
width: screenResolution?.width ? `${screenResolution.width}px` : "100%",
height: screenResolution?.height ? `${screenResolution.height}px` : "auto",
minHeight: "200px",
}}
>
{components.map((component) => {
const { position = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
return (
<div
key={component.id}
className="absolute"
style={{
left: position.x || 0,
top: position.y || 0,
width: size.width || 200,
height: size.height || 40,
zIndex: position.z || 1,
}}
>
<DynamicComponentRenderer
component={component}
isInteractive={true}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
userId={userId}
userName={userName}
companyCode={user?.companyCode}
formData={enhancedFormData}
onFormDataChange={onFormDataChange}
groupedData={groupedData}
onSave={onSave}
/>
</div>
);
})}
</div>
return (
<div
key={component.id}
className="absolute"
style={{
left: position.x || 0,
top: position.y || 0,
width: size.width || 200,
height: size.height || 40,
zIndex: position.z || 1,
}}
>
<DynamicComponentRenderer
component={component}
isInteractive={true}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
userId={userId}
userName={userName}
companyCode={user?.companyCode}
formData={enhancedFormData}
onFormDataChange={onFormDataChange}
groupedData={groupedData}
onSave={hasUniversalFormModal ? undefined : onSave}
/>
</div>
);
})}
</div>
);
})()}
</div>
)}
</>

View File

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

View File

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

View File

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

View File

@ -40,14 +40,7 @@ interface SortableRowProps {
}
function SortableRow({ id, children, className }: SortableRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
@ -94,8 +87,8 @@ export function RepeaterTable({
// 컨테이너 ref - 실제 너비 측정용
const containerRef = useRef<HTMLDivElement>(null);
// 균등 분배 모드 상태 (true일 때 테이블이 컨테이너에 맞춤)
const [isEqualizedMode, setIsEqualizedMode] = useState(false);
// 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행)
const initializedRef = useRef(false);
// DnD 센서 설정
const sensors = useSensors(
@ -106,7 +99,7 @@ export function RepeaterTable({
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
// 드래그 종료 핸들러
@ -178,89 +171,103 @@ export function RepeaterTable({
startX: e.clientX,
startWidth: columnWidths[field] || 120,
});
// 수동 조정 시 균등 분배 모드 해제
setIsEqualizedMode(false);
};
// 컬럼 확장 상태 추적 (토글용)
const [expandedColumns, setExpandedColumns] = useState<Set<string>>(new Set());
// 컨테이너 가용 너비 계산
const getAvailableWidth = (): number => {
if (!containerRef.current) return 800;
const containerWidth = containerRef.current.offsetWidth;
// 드래그 핸들(32px) + 체크박스 컬럼(40px) + border(2px)
return containerWidth - 74;
};
// 데이터 기준 최적 너비 계산
const calculateAutoFitWidth = (field: string): number => {
const column = columns.find(col => col.field === field);
if (!column) return 120;
// 텍스트 너비 계산 (한글/영문/숫자 혼합 고려)
const measureTextWidth = (text: string): number => {
if (!text) return 0;
let width = 0;
for (const char of text) {
if (/[가-힣]/.test(char)) {
width += 15; // 한글 (text-xs 12px 기준)
} else if (/[a-zA-Z]/.test(char)) {
width += 9; // 영문
} else if (/[0-9]/.test(char)) {
width += 8; // 숫자
} else if (/[_\-.]/.test(char)) {
width += 6; // 특수문자
} else if (/[\(\)]/.test(char)) {
width += 6; // 괄호
} else {
width += 8; // 기타
}
}
return width;
};
// 헤더 텍스트 길이 (대략 8px per character + padding)
const headerWidth = (column.label?.length || field.length) * 8 + 40;
// 해당 컬럼의 가장 긴 글자 너비 계산
// equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용)
const calculateColumnContentWidth = (field: string, equalWidth: number): number => {
const column = columns.find((col) => col.field === field);
if (!column) return equalWidth;
// 데이터 중 가장 긴 텍스트 찾기
// 날짜 필드는 110px (yyyy-MM-dd)
if (column.type === "date") {
return 110;
}
// 해당 컬럼에 값이 있는지 확인
let hasValue = false;
let maxDataWidth = 0;
data.forEach(row => {
data.forEach((row) => {
const value = row[field];
if (value !== undefined && value !== null) {
if (value !== undefined && value !== null && value !== "") {
hasValue = true;
let displayText = String(value);
// 숫자는 천단위 구분자 포함
if (typeof value === 'number') {
if (typeof value === "number") {
displayText = value.toLocaleString();
}
// 날짜는 yyyy-mm-dd 형식
if (column.type === 'date' && displayText.includes('T')) {
displayText = displayText.split('T')[0];
}
// 대략적인 너비 계산 (8px per character + padding)
const textWidth = displayText.length * 8 + 32;
const textWidth = measureTextWidth(displayText) + 20; // padding
maxDataWidth = Math.max(maxDataWidth, textWidth);
}
});
// 헤더와 데이터 중 큰 값 사용, 최소 60px, 최대 400px
const optimalWidth = Math.max(headerWidth, maxDataWidth);
return Math.min(Math.max(optimalWidth, 60), 400);
};
// 값이 없으면 균등 분배 너비 사용
if (!hasValue) {
return equalWidth;
}
// 더블클릭으로 auto-fit / 기본 너비 토글
const handleDoubleClick = (field: string) => {
// 개별 컬럼 조정 시 균등 분배 모드 해제
setIsEqualizedMode(false);
setExpandedColumns(prev => {
const newSet = new Set(prev);
if (newSet.has(field)) {
// 확장 상태 → 기본 너비로 복구
newSet.delete(field);
setColumnWidths(prevWidths => ({
...prevWidths,
[field]: defaultWidths[field] || 120,
}));
} else {
// 기본 상태 → 데이터 기준 auto-fit
newSet.add(field);
const autoWidth = calculateAutoFitWidth(field);
setColumnWidths(prevWidths => ({
...prevWidths,
[field]: autoWidth,
}));
// 헤더 텍스트 너비 (동적 데이터 소스가 있으면 headerLabel 사용)
let headerText = column.label || field;
if (column.dynamicDataSource?.enabled && column.dynamicDataSource.options.length > 0) {
const activeOptionId = activeDataSources[field] || column.dynamicDataSource.defaultOptionId;
const activeOption = column.dynamicDataSource.options.find((opt) => opt.id === activeOptionId)
|| column.dynamicDataSource.options[0];
if (activeOption?.headerLabel) {
headerText = activeOption.headerLabel;
}
return newSet;
});
}
const headerWidth = measureTextWidth(headerText) + 32; // padding + 드롭다운 아이콘
// 헤더와 데이터 중 큰 값 사용
return Math.max(headerWidth, maxDataWidth);
};
// 균등 분배 트리거 감지
useEffect(() => {
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
if (!containerRef.current) return;
// 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤
const handleDoubleClick = (field: string) => {
const availableWidth = getAvailableWidth();
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
const contentWidth = calculateColumnContentWidth(field, equalWidth);
setColumnWidths((prev) => ({
...prev,
[field]: contentWidth,
}));
};
// 실제 컨테이너 너비 측정
const containerWidth = containerRef.current.offsetWidth;
// 체크박스 컬럼 너비(40px) + 테이블 border(2px) 제외한 가용 너비 계산
const checkboxColumnWidth = 40;
const borderWidth = 2;
const availableWidth = containerWidth - checkboxColumnWidth - borderWidth;
// 컬럼 수로 나눠서 균등 분배 (최소 60px 보장)
// 균등 분배: 컬럼 수로 테이블 너비를 균등 분배
const applyEqualizeWidths = () => {
const availableWidth = getAvailableWidth();
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
const newWidths: Record<string, number> = {};
@ -269,9 +276,62 @@ export function RepeaterTable({
});
setColumnWidths(newWidths);
setExpandedColumns(new Set()); // 확장 상태 초기화
setIsEqualizedMode(true); // 균등 분배 모드 활성화
}, [equalizeWidthsTrigger, columns]);
};
// 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
const applyAutoFitWidths = () => {
if (columns.length === 0) return;
// 균등 분배 너비 계산 (값이 없는 컬럼의 최소값)
const availableWidth = getAvailableWidth();
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
// 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용)
const newWidths: Record<string, number> = {};
columns.forEach((col) => {
newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth);
});
// 2. 컨테이너 너비와 비교
const totalContentWidth = Object.values(newWidths).reduce((sum, w) => sum + w, 0);
// 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지)
if (totalContentWidth < availableWidth) {
const extraSpace = availableWidth - totalContentWidth;
const extraPerColumn = Math.floor(extraSpace / columns.length);
columns.forEach((col) => {
newWidths[col.field] += extraPerColumn;
});
}
// 컨테이너보다 크면 그대로 (스크롤 생성됨)
setColumnWidths(newWidths);
};
// 초기 마운트 시 균등 분배 적용
useEffect(() => {
if (initializedRef.current) return;
if (!containerRef.current || columns.length === 0) return;
const timer = setTimeout(() => {
applyEqualizeWidths();
initializedRef.current = true;
}, 100);
return () => clearTimeout(timer);
}, [columns]);
// 트리거 감지: 1=균등분배, 2=자동맞춤
useEffect(() => {
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
// 홀수면 자동맞춤, 짝수면 균등분배 (토글 방식)
if (equalizeWidthsTrigger % 2 === 1) {
applyAutoFitWidths();
} else {
applyEqualizeWidths();
}
}, [equalizeWidthsTrigger]);
useEffect(() => {
if (!resizing) return;
@ -336,13 +396,8 @@ export function RepeaterTable({
const isAllSelected = data.length > 0 && selectedRows.size === data.length;
const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length;
const renderCell = (
row: any,
column: RepeaterColumnConfig,
rowIndex: number
) => {
const isEditing =
editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
const renderCell = (row: any, column: RepeaterColumnConfig, rowIndex: number) => {
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
const value = row[column.field];
// 계산 필드는 편집 불가
@ -360,13 +415,7 @@ export function RepeaterTable({
}
};
return (
<div className="px-2 py-1">
{column.type === "number"
? formatNumber(value)
: value || "-"}
</div>
);
return <div className="px-2 py-1">{column.type === "number" ? formatNumber(value) : value || "-"}</div>;
}
// 편집 가능한 필드
@ -377,22 +426,22 @@ export function RepeaterTable({
if (value === undefined || value === null || value === "") return "";
const num = typeof value === "number" ? value : parseFloat(value);
if (isNaN(num)) return "";
// 정수면 소수점 없이, 소수면 소수점 유지
if (Number.isInteger(num)) {
return num.toString();
} else {
return num.toString();
}
return num.toString();
})();
return (
<Input
type="number"
type="text"
inputMode="numeric"
value={displayValue}
onChange={(e) =>
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
onChange={(e) => {
const val = e.target.value;
// 숫자와 소수점만 허용
if (val === "" || /^-?\d*\.?\d*$/.test(val)) {
handleCellEdit(rowIndex, column.field, val === "" ? 0 : parseFloat(val) || 0);
}
}}
className="h-8 w-full min-w-0 rounded-none border-gray-200 text-right text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
);
@ -416,23 +465,19 @@ export function RepeaterTable({
};
return (
<Input
<input
type="date"
value={formatDateValue(value)}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
onClick={(e) => (e.target as HTMLInputElement).showPicker?.()}
className="h-8 w-full min-w-0 cursor-pointer rounded-none border border-gray-200 bg-white px-2 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-inner-spin-button]:hidden"
/>
);
case "select":
return (
<Select
value={value || ""}
onValueChange={(newValue) =>
handleCellEdit(rowIndex, column.field, newValue)
}
>
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full">
<Select value={value || ""} onValueChange={(newValue) => handleCellEdit(rowIndex, column.field, newValue)}>
<SelectTrigger className="h-8 w-full min-w-0 rounded-none border-gray-200 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -451,7 +496,7 @@ export function RepeaterTable({
type="text"
value={value || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
className="h-8 w-full min-w-0 rounded-none border-gray-200 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
);
}
@ -461,126 +506,124 @@ export function RepeaterTable({
const sortableItems = data.map((_, idx) => `row-${idx}`);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div ref={containerRef} className="border border-gray-200 bg-white">
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
<div className="max-h-[400px] overflow-x-auto overflow-y-auto">
<table
className={cn(
"text-xs border-collapse",
isEqualizedMode && "w-full"
)}
style={isEqualizedMode ? undefined : { minWidth: "max-content" }}
className="border-collapse text-xs"
style={{
width: `max(100%, ${Object.values(columnWidths).reduce((sum, w) => sum + w, 0) + 74}px)`,
}}
>
<thead className="bg-gray-50 sticky top-0 z-10">
<thead className="sticky top-0 z-10 bg-gray-50">
<tr>
{/* 드래그 핸들 헤더 */}
<th className="px-1 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-8">
<th className="w-8 border-r border-b border-gray-200 px-1 py-2 text-center font-medium text-gray-700">
<span className="sr-only"></span>
</th>
{/* 체크박스 헤더 */}
<th className="px-3 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-10">
<th className="w-10 border-r border-b border-gray-200 px-3 py-2 text-center font-medium text-gray-700">
<Checkbox
checked={isAllSelected}
// @ts-ignore - indeterminate는 HTML 속성
// @ts-expect-error - indeterminate는 HTML 속성
data-indeterminate={isIndeterminate}
onCheckedChange={handleSelectAll}
className={cn(
"border-gray-400",
isIndeterminate && "data-[state=checked]:bg-primary"
)}
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
/>
</th>
{columns.map((col) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
const activeOption = hasDynamicSource
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
: null;
{columns.map((col) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
const activeOption = hasDynamicSource
? col.dynamicDataSource!.options.find((opt) => opt.id === activeOptionId) ||
col.dynamicDataSource!.options[0]
: null;
const isExpanded = expandedColumns.has(col.field);
return (
<th
key={col.field}
className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none"
style={{ width: `${columnWidths[col.field]}px` }}
onDoubleClick={() => handleDoubleClick(col.field)}
title={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"}
>
<div className="flex items-center justify-between pointer-events-none">
<div className="flex items-center gap-1 pointer-events-auto">
{hasDynamicSource ? (
<Popover
open={openPopover === col.field}
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center gap-1 hover:text-blue-600 transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded px-1 -mx-1"
)}
>
<span>{col.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-auto min-w-[160px] p-1"
align="start"
sideOffset={4}
return (
<th
key={col.field}
className="group relative cursor-pointer border-r border-b border-gray-200 px-3 py-2 text-left font-medium whitespace-nowrap text-gray-700 select-none"
style={{ width: `${columnWidths[col.field]}px` }}
onDoubleClick={() => handleDoubleClick(col.field)}
title="더블클릭하여 글자 너비에 맞춤"
>
<div className="pointer-events-none flex items-center justify-between">
<div className="pointer-events-auto flex items-center gap-1">
{hasDynamicSource ? (
<Popover
open={openPopover === col.field}
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
>
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
</div>
{col.dynamicDataSource!.options.map((option) => (
<PopoverTrigger asChild>
<button
key={option.id}
type="button"
onClick={() => {
onDataSourceChange?.(col.field, option.id);
setOpenPopover(null);
}}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
"hover:bg-accent hover:text-accent-foreground transition-colors",
"focus:outline-none focus-visible:bg-accent",
activeOption?.id === option.id && "bg-accent/50"
"inline-flex items-center gap-1 transition-colors hover:text-blue-600",
"-mx-1 rounded px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
)}
>
<Check
className={cn(
"h-3 w-3",
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
)}
/>
<span>{option.label}</span>
{/* 컬럼명 - 선택된 옵션라벨 형식으로 표시 */}
<span>
{activeOption?.headerLabel || `${col.label} - ${activeOption?.label || ''}`}
</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
))}
</PopoverContent>
</Popover>
) : (
<>
{col.label}
{col.required && <span className="text-red-500 ml-1">*</span>}
</>
)}
</PopoverTrigger>
<PopoverContent className="w-auto min-w-[160px] p-1" align="start" sideOffset={4}>
<div className="text-muted-foreground mb-1 border-b px-2 py-1 text-[10px]">
</div>
{col.dynamicDataSource!.options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
onDataSourceChange?.(col.field, option.id);
setOpenPopover(null);
// 옵션 변경 시 해당 컬럼 너비 재계산
if (option.headerLabel) {
const newHeaderWidth = measureTextWidth(option.headerLabel) + 32;
setColumnWidths((prev) => ({
...prev,
[col.field]: Math.max(prev[col.field] || 60, newHeaderWidth),
}));
}
}}
className={cn(
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs",
"hover:bg-accent hover:text-accent-foreground transition-colors",
"focus-visible:bg-accent focus:outline-none",
activeOption?.id === option.id && "bg-accent/50",
)}
>
<Check
className={cn(
"h-3 w-3",
activeOption?.id === option.id ? "opacity-100" : "opacity-0",
)}
/>
<span>{option.label}</span>
</button>
))}
</PopoverContent>
</Popover>
) : (
<>
{col.label}
{col.required && <span className="ml-1 text-red-500">*</span>}
</>
)}
</div>
{/* 리사이즈 핸들 */}
<div
className="pointer-events-auto absolute top-0 right-0 bottom-0 w-1 cursor-col-resize opacity-0 transition-opacity group-hover:opacity-100 hover:bg-blue-500"
onMouseDown={(e) => handleMouseDown(e, col.field)}
title="드래그하여 너비 조정"
/>
</div>
{/* 리사이즈 핸들 */}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"
onMouseDown={(e) => handleMouseDown(e, col.field)}
title="드래그하여 너비 조정"
/>
</div>
</th>
);
})}
</th>
);
})}
</tr>
</thead>
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
@ -589,7 +632,7 @@ export function RepeaterTable({
<tr>
<td
colSpan={columns.length + 2}
className="px-4 py-8 text-center text-gray-500 border-b border-gray-200"
className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
>
</td>
@ -600,19 +643,19 @@ export function RepeaterTable({
key={`row-${rowIndex}`}
id={`row-${rowIndex}`}
className={cn(
"hover:bg-blue-50/50 transition-colors",
selectedRows.has(rowIndex) && "bg-blue-50"
"transition-colors hover:bg-blue-50/50",
selectedRows.has(rowIndex) && "bg-blue-50",
)}
>
{({ attributes, listeners, isDragging }) => (
<>
{/* 드래그 핸들 */}
<td className="px-1 py-1 text-center border-b border-r border-gray-200">
<td className="border-r border-b border-gray-200 px-1 py-1 text-center">
<button
type="button"
className={cn(
"cursor-grab p-1 rounded hover:bg-gray-100 transition-colors",
isDragging && "cursor-grabbing"
"cursor-grab rounded p-1 transition-colors hover:bg-gray-100",
isDragging && "cursor-grabbing",
)}
{...attributes}
{...listeners}
@ -621,7 +664,7 @@ export function RepeaterTable({
</button>
</td>
{/* 체크박스 */}
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
<td className="border-r border-b border-gray-200 px-3 py-1 text-center">
<Checkbox
checked={selectedRows.has(rowIndex)}
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
@ -632,8 +675,11 @@ export function RepeaterTable({
{columns.map((col) => (
<td
key={col.field}
className="px-1 py-1 border-b border-r border-gray-200 overflow-hidden"
style={{ width: `${columnWidths[col.field]}px`, maxWidth: `${columnWidths[col.field]}px` }}
className="overflow-hidden border-r border-b border-gray-200 px-1 py-1"
style={{
width: `${columnWidths[col.field]}px`,
maxWidth: `${columnWidths[col.field]}px`,
}}
>
{renderCell(row, col, rowIndex)}
</td>
@ -651,4 +697,3 @@ export function RepeaterTable({
</DndContext>
);
}

View File

@ -17,6 +17,7 @@ export interface ModalRepeaterTableProps {
modalTitle: string; // 모달 제목 (예: "품목 검색 및 선택")
modalButtonText?: string; // 모달 열기 버튼 텍스트 (기본: "품목 검색")
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
modalFilters?: ModalFilterConfig[]; // 모달 내 필터 설정
// Repeater 테이블 설정
columns: RepeaterColumnConfig[]; // 테이블 컬럼 설정
@ -75,6 +76,7 @@ export interface DynamicDataSourceConfig {
export interface DynamicDataSourceOption {
id: string;
label: string; // 표시 라벨 (예: "거래처별 단가")
headerLabel?: string; // 헤더에 표시될 전체 라벨 (예: "단가 - 거래처별 단가")
// 조회 방식
sourceType: "table" | "multiTable" | "api";
@ -175,6 +177,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 +198,7 @@ export interface ItemSelectionModalProps {
uniqueField?: string;
onSelect: (items: Record<string, unknown>[]) => void;
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
// 모달 내부 필터 (사용자 선택 가능)
modalFilters?: ModalFilterConfig[];
}

View File

@ -0,0 +1,884 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Plus, Columns, AlignJustify } 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 lookup이 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은 별도로 처리
};
// lookup 설정을 dynamicDataSource로 변환 (새로운 조회 기능)
if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) {
baseColumn.dynamicDataSource = {
enabled: true,
options: col.lookup.options.map((option) => ({
id: option.id,
// "컬럼명 - 옵션라벨" 형식으로 헤더에 표시
label: option.displayLabel || option.label,
// 헤더에 표시될 전체 라벨 (컬럼명 - 옵션라벨)
headerLabel: `${col.label} - ${option.displayLabel || option.label}`,
sourceType: "table" as const,
tableConfig: {
tableName: option.tableName,
valueColumn: option.valueColumn,
joinConditions: option.conditions.map((cond) => ({
sourceField: cond.sourceField,
targetField: cond.targetColumn,
// sourceType에 따른 데이터 출처 설정
sourceType: cond.sourceType, // "currentRow" | "sectionField" | "externalTable"
fromFormData: cond.sourceType === "sectionField",
sectionId: cond.sectionId,
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환)
transform: cond.transform?.enabled ? {
tableName: cond.transform.tableName,
matchColumn: cond.transform.matchColumn,
resultColumn: cond.transform.resultColumn,
} : undefined,
})),
},
// 조회 유형 정보 추가
lookupType: option.type,
})),
defaultOptionId: col.lookup.options.find((o) => o.isDefault)?.id || col.lookup.options[0]?.id,
};
}
// columnModes를 dynamicDataSource로 변환 (기존 로직 유지)
else 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 || "",
valueColumn: mode.valueMapping?.externalRef?.valueColumn || "",
joinConditions: (mode.valueMapping?.externalRef?.joinConditions || []).map((jc) => ({
sourceField: jc.sourceField,
targetField: jc.targetColumn,
})),
},
})),
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,
};
}
/**
* 함수: 중간
* : 거래처 "(무)테스트업체" "CUST-0002"
*/
async function transformValue(
value: any,
transform: { tableName: string; matchColumn: string; resultColumn: string }
): Promise<any> {
if (!value || !transform.tableName || !transform.matchColumn || !transform.resultColumn) {
return value;
}
try {
// 정확히 일치하는 검색
const response = await apiClient.post(
`/table-management/tables/${transform.tableName}/data`,
{
search: {
[transform.matchColumn]: {
value: value,
operator: "equals"
}
},
size: 1,
page: 1
}
);
if (response.data.success && response.data.data?.data?.length > 0) {
const transformedValue = response.data.data.data[0][transform.resultColumn];
return transformedValue;
}
console.warn(`변환 실패: ${transform.tableName}.${transform.matchColumn} = "${value}" 인 행을 찾을 수 없습니다.`);
return undefined;
} catch (error) {
console.error("값 변환 오류:", error);
return undefined;
}
}
/**
*
* LookupCondition.sourceType이 "externalTable"
*/
async function fetchExternalLookupValue(
externalLookup: {
tableName: string;
matchColumn: string;
matchSourceType: "currentRow" | "sourceTable" | "sectionField";
matchSourceField: string;
matchSectionId?: string;
resultColumn: string;
},
rowData: any,
sourceData: any,
formData: FormDataState
): Promise<any> {
// 1. 비교 값 가져오기
let matchValue: any;
if (externalLookup.matchSourceType === "currentRow") {
matchValue = rowData[externalLookup.matchSourceField];
} else if (externalLookup.matchSourceType === "sourceTable") {
matchValue = sourceData?.[externalLookup.matchSourceField];
} else {
matchValue = formData[externalLookup.matchSourceField];
}
if (matchValue === undefined || matchValue === null || matchValue === "") {
console.warn(`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`);
return undefined;
}
// 2. 외부 테이블에서 값 조회 (정확히 일치하는 검색)
try {
const response = await apiClient.post(
`/table-management/tables/${externalLookup.tableName}/data`,
{
search: {
[externalLookup.matchColumn]: {
value: matchValue,
operator: "equals"
}
},
size: 1,
page: 1
}
);
if (response.data.success && response.data.data?.data?.length > 0) {
return response.data.data.data[0][externalLookup.resultColumn];
}
console.warn(`외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`);
return undefined;
} catch (error) {
console.error("외부 테이블 조회 오류:", error);
return undefined;
}
}
/**
*
*
* @param tableName -
* @param valueColumn -
* @param joinConditions -
* @param rowData - ( )
* @param sourceData - (_sourceData)
* @param formData - ( )
*/
async function fetchExternalValue(
tableName: string,
valueColumn: string,
joinConditions: TableJoinCondition[],
rowData: any,
sourceData: any,
formData: FormDataState
): Promise<any> {
if (joinConditions.length === 0) {
return undefined;
}
try {
const whereConditions: Record<string, any> = {};
for (const condition of joinConditions) {
let value: any;
// 값 출처에 따라 가져오기 (4가지 소스 타입 지원)
if (condition.sourceType === "row") {
// 현재 행 데이터 (설정된 컬럼 필드)
value = rowData[condition.sourceField];
} else if (condition.sourceType === "sourceData") {
// 원본 소스 테이블 데이터 (_sourceData)
value = sourceData?.[condition.sourceField];
} else if (condition.sourceType === "formData") {
// formData에서 가져오기 (다른 섹션)
value = formData[condition.sourceField];
} else if (condition.sourceType === "externalTable" && condition.externalLookup) {
// 외부 테이블에서 조회하여 가져오기
value = await fetchExternalLookupValue(condition.externalLookup, rowData, sourceData, formData);
}
if (value === undefined || value === null || value === "") {
return undefined;
}
// 값 변환이 필요한 경우 (예: 이름 → 코드) - 레거시 호환
if (condition.transform) {
value = await transformValue(value, condition.transform);
if (value === undefined) {
return undefined;
}
}
// 숫자형 ID 변환
let convertedValue = value;
if (condition.targetColumn.endsWith("_id") || condition.targetColumn === "id") {
const numValue = Number(value);
if (!isNaN(numValue)) {
convertedValue = numValue;
}
}
// 정확히 일치하는 검색을 위해 operator: "equals" 사용
whereConditions[condition.targetColumn] = {
value: convertedValue,
operator: "equals"
};
}
// 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 [widthTrigger, setWidthTrigger] = useState(0);
// 동적 데이터 소스 활성화 상태
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
// 날짜 일괄 적용 완료 플래그 (컬럼별로 한 번만 적용)
const [batchAppliedFields, setBatchAppliedFields] = useState<Set<string>>(new Set());
// 초기 데이터 로드 완료 플래그 (무한 루프 방지)
const initialDataLoadedRef = React.useRef(false);
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
useEffect(() => {
// 이미 초기화되었으면 스킵
if (initialDataLoadedRef.current) return;
const tableSectionKey = `_tableSection_${sectionId}`;
const initialData = formData[tableSectionKey];
if (Array.isArray(initialData) && initialData.length > 0) {
console.log("[TableSectionRenderer] 초기 데이터 로드:", {
sectionId,
itemCount: initialData.length,
});
setTableData(initialData);
initialDataLoadedRef.current = true;
}
}, [sectionId, formData]);
// 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[]) => {
let processedData = newData;
// 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리
const batchApplyColumns = tableConfig.columns.filter(
(col) => col.type === "date" && col.batchApply === true
);
for (const dateCol of batchApplyColumns) {
// 이미 일괄 적용된 컬럼은 건너뜀
if (batchAppliedFields.has(dateCol.field)) continue;
// 해당 컬럼에 값이 있는 행과 없는 행 분류
const itemsWithDate = processedData.filter((item) => item[dateCol.field]);
const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]);
// 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
const selectedDate = itemsWithDate[0][dateCol.field];
// 모든 행에 동일한 날짜 적용
processedData = processedData.map((item) => ({
...item,
[dateCol.field]: selectedDate,
}));
// 플래그 활성화 (이후 개별 수정 가능)
setBatchAppliedFields((prev) => new Set([...prev, dateCol.field]));
}
}
setTableData(processedData);
onTableDataChange(processedData);
},
[onTableDataChange, tableConfig.columns, batchAppliedFields]
);
// 행 변경 핸들러
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());
// 데이터가 모두 삭제되면 일괄 적용 플래그도 리셋
if (newData.length === 0) {
setBatchAppliedFields(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;
// 0. lookup 설정이 있는 경우 (동적 조회)
if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) {
// 현재 활성화된 옵션 또는 기본 옵션 사용
const activeOptionId = activeDataSources[col.field];
const defaultOption = col.lookup.options.find((o) => o.isDefault) || col.lookup.options[0];
const selectedOption = activeOptionId
? col.lookup.options.find((o) => o.id === activeOptionId) || defaultOption
: defaultOption;
if (selectedOption) {
// sameTable 타입: 소스 데이터에서 직접 값 복사
if (selectedOption.type === "sameTable") {
const value = sourceItem[selectedOption.valueColumn];
if (value !== undefined) {
newItem[col.field] = value;
}
// _sourceData에 원본 저장 (나중에 다른 옵션으로 전환 시 사용)
newItem._sourceData = sourceItem;
continue;
}
// relatedTable, combinedLookup: 외부 테이블 조회
// 조인 조건 구성 (4가지 소스 타입 지원)
const joinConditions: TableJoinCondition[] = selectedOption.conditions.map((cond) => {
// sourceType 매핑
let sourceType: "row" | "sourceData" | "formData" | "externalTable";
if (cond.sourceType === "currentRow") {
sourceType = "row";
} else if (cond.sourceType === "sourceTable") {
sourceType = "sourceData";
} else if (cond.sourceType === "externalTable") {
sourceType = "externalTable";
} else {
sourceType = "formData";
}
return {
sourceType,
sourceField: cond.sourceField,
targetColumn: cond.targetColumn,
// 외부 테이블 조회 설정
externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환)
transform: cond.transform?.enabled ? {
tableName: cond.transform.tableName,
matchColumn: cond.transform.matchColumn,
resultColumn: cond.transform.resultColumn,
} : undefined,
};
});
// 외부 테이블에서 값 조회 (sourceItem이 _sourceData 역할)
const value = await fetchExternalValue(
selectedOption.tableName,
selectedOption.valueColumn,
joinConditions,
{ ...sourceItem, ...newItem }, // rowData (현재 행)
sourceItem, // sourceData (소스 테이블 원본)
formData
);
if (value !== undefined) {
newItem[col.field] = value;
}
// _sourceData에 원본 저장
newItem._sourceData = sourceItem;
}
continue;
}
// 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 }, // rowData
sourceItem, // sourceData
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, activeDataSources]
);
// 컬럼 모드/조회 옵션 변경 핸들러
const handleDataSourceChange = useCallback(
async (columnField: string, optionId: string) => {
setActiveDataSources((prev) => ({
...prev,
[columnField]: optionId,
}));
// 해당 컬럼의 모든 행 데이터 재조회
const column = tableConfig.columns.find((col) => col.field === columnField);
// lookup 설정이 있는 경우 (새로운 조회 기능)
if (column?.lookup?.enabled && column.lookup.options) {
const selectedOption = column.lookup.options.find((opt) => opt.id === optionId);
if (!selectedOption) return;
// sameTable 타입: 현재 행의 소스 데이터에서 값 복사 (외부 조회 필요 없음)
if (selectedOption.type === "sameTable") {
const updatedData = tableData.map((row) => {
// sourceField에서 값을 가져와 해당 컬럼에 복사
// row에 _sourceData가 있으면 거기서, 없으면 row 자체에서 가져옴
const sourceData = row._sourceData || row;
const newValue = sourceData[selectedOption.valueColumn] ?? row[columnField];
return { ...row, [columnField]: newValue };
});
const calculatedData = calculateAll(updatedData);
handleDataChange(calculatedData);
return;
}
// 모든 행에 대해 새 값 조회
const updatedData = await Promise.all(
tableData.map(async (row) => {
let newValue: any = row[columnField];
// 조인 조건 구성 (4가지 소스 타입 지원)
const joinConditions: TableJoinCondition[] = selectedOption.conditions.map((cond) => {
// sourceType 매핑
let sourceType: "row" | "sourceData" | "formData" | "externalTable";
if (cond.sourceType === "currentRow") {
sourceType = "row";
} else if (cond.sourceType === "sourceTable") {
sourceType = "sourceData";
} else if (cond.sourceType === "externalTable") {
sourceType = "externalTable";
} else {
sourceType = "formData";
}
return {
sourceType,
sourceField: cond.sourceField,
targetColumn: cond.targetColumn,
// 외부 테이블 조회 설정
externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환)
transform: cond.transform?.enabled ? {
tableName: cond.transform.tableName,
matchColumn: cond.transform.matchColumn,
resultColumn: cond.transform.resultColumn,
} : undefined,
};
});
// 외부 테이블에서 값 조회 (_sourceData 전달)
const sourceData = row._sourceData || row;
const value = await fetchExternalValue(
selectedOption.tableName,
selectedOption.valueColumn,
joinConditions,
row,
sourceData,
formData
);
if (value !== undefined) {
newValue = value;
}
return { ...row, [columnField]: newValue };
})
);
// 계산 필드 업데이트
const calculatedData = calculateAll(updatedData);
handleDataChange(calculatedData);
return;
}
// 기존 columnModes 처리 (레거시 호환)
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];
const sourceData = row._sourceData || row;
if (mapping.type === "external" && mapping.externalRef) {
const { tableName, valueColumn, joinConditions } = mapping.externalRef;
const value = await fetchExternalValue(tableName, valueColumn, joinConditions, row, sourceData, 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,
// category 타입을 select로 변환 (ModalFilterConfig 호환)
type: filter.type === "category" ? "select" as const : filter.type as "text" | "select",
options: filter.options,
categoryRef: filter.categoryRef,
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={() => setWidthTrigger((prev) => prev + 1)}
className="h-7 text-xs px-2"
title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"}
>
{widthTrigger % 2 === 0 ? (
<>
<AlignJustify className="h-3.5 w-3.5 mr-1" />
</>
) : (
<>
<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={widthTrigger}
/>
{/* 항목 선택 모달 */}
<ItemSelectionModal
open={modalOpen}
onOpenChange={setModalOpen}
sourceTable={sourceTable}
sourceColumns={sourceColumns}
sourceSearchFields={sourceSearchFields}
multiSelect={multiSelect}
filterCondition={baseFilterCondition}
modalTitle={modalTitle}
alreadySelected={tableData}
uniqueField={tableConfig.saveConfig?.uniqueField}
onSelect={handleAddItems}
columnLabels={columnLabels}
modalFilters={modalFiltersForModal}
/>
</div>
);
}

View File

@ -38,6 +38,7 @@ import {
OptionalFieldGroupConfig,
} from "./types";
import { defaultConfig, generateUniqueId } from "./config";
import { TableSectionRenderer } from "./TableSectionRenderer";
/**
* 🔗 Select
@ -194,6 +195,10 @@ export function UniversalFormModalComponent({
// 로딩 상태
const [saving, setSaving] = useState(false);
// 🆕 수정 모드: 원본 그룹 데이터 (INSERT/UPDATE/DELETE 추적용)
const [originalGroupedData, setOriginalGroupedData] = useState<any[]>([]);
const groupedDataInitializedRef = useRef(false);
// 삭제 확인 다이얼로그
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean;
@ -303,6 +308,12 @@ export function UniversalFormModalComponent({
console.log(`[UniversalFormModal] 반복 섹션 병합: ${sectionKey}`, items);
}
}
// 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용)
if (originalGroupedData.length > 0) {
event.detail.formData._originalGroupedData = originalGroupedData;
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}`);
}
};
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
@ -310,7 +321,37 @@ export function UniversalFormModalComponent({
return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
}, [formData, repeatSections, config.sections]);
}, [formData, repeatSections, config.sections, originalGroupedData]);
// 🆕 수정 모드: _groupedData가 있으면 테이블 섹션 초기화
useEffect(() => {
if (!_groupedData || _groupedData.length === 0) return;
if (groupedDataInitializedRef.current) return; // 이미 초기화됨
// 테이블 타입 섹션 찾기
const tableSection = config.sections.find((s) => s.type === "table");
if (!tableSection) {
console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시");
return;
}
console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", {
sectionId: tableSection.id,
itemCount: _groupedData.length,
});
// 원본 데이터 저장 (수정/삭제 추적용)
setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData)));
// 테이블 섹션 데이터 설정
const tableSectionKey = `_tableSection_${tableSection.id}`;
setFormData((prev) => ({
...prev,
[tableSectionKey]: _groupedData,
}));
groupedDataInitializedRef.current = true;
}, [_groupedData, config.sections]);
// 필드 레벨 linkedFieldGroup 데이터 로드
useEffect(() => {
@ -372,9 +413,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 +492,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 +523,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 +825,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 === "") {
@ -800,16 +844,27 @@ 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 +877,140 @@ export function UniversalFormModalComponent({
}
}
// 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장)
// targetTable이 없거나 메인 테이블과 같은 경우
const tableSectionsForMainTable = config.sections.filter(
(s) => s.type === "table" &&
(!s.tableConfig?.saveConfig?.targetTable ||
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName)
);
if (tableSectionsForMainTable.length > 0) {
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
const commonFieldsData: Record<string, any> = {};
const { sectionSaveModes } = config.saveConfig;
// 필드 타입 섹션에서 공통 저장 필드 수집
for (const section of config.sections) {
if (section.type === "table") continue;
const sectionMode = sectionSaveModes?.find((s) => s.sectionId === section.id);
const defaultMode = "common"; // 필드 타입 섹션의 기본값은 공통 저장
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
if (section.fields) {
for (const field of section.fields) {
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
if (fieldSaveMode === "common" && dataToSave[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = dataToSave[field.columnName];
}
}
}
}
// 각 테이블 섹션의 품목 데이터에 공통 필드 병합하여 저장
for (const tableSection of tableSectionsForMainTable) {
const sectionData = tableSectionData[tableSection.id] || [];
if (sectionData.length > 0) {
// 품목별로 행 저장
for (const item of sectionData) {
const rowToSave = { ...commonFieldsData, ...item };
// _sourceData 등 내부 메타데이터 제거
Object.keys(rowToSave).forEach((key) => {
if (key.startsWith("_")) {
delete rowToSave[key];
}
});
const response = await apiClient.post(
`/table-management/tables/${config.saveConfig.tableName}/add`,
rowToSave
);
if (!response.data?.success) {
throw new Error(response.data?.message || "품목 저장 실패");
}
}
// 이미 저장했으므로 아래 로직에서 다시 저장하지 않도록 제거
delete tableSectionData[tableSection.id];
}
}
// 품목이 없으면 공통 데이터만 저장하지 않음 (품목이 필요한 화면이므로)
// 다른 테이블 섹션이 있는 경우에만 메인 데이터 저장
const hasOtherTableSections = Object.keys(tableSectionData).length > 0;
if (!hasOtherTableSections) {
return; // 메인 테이블에 저장할 품목이 없으면 종료
}
}
// 메인 데이터 저장 (테이블 섹션이 없거나 별도 테이블에 저장하는 경우)
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;
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
const commonFieldsData: Record<string, any> = {};
const { sectionSaveModes } = config.saveConfig;
if (sectionSaveModes && sectionSaveModes.length > 0) {
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
for (const otherSection of config.sections) {
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id);
const defaultMode = otherSection.type === "table" ? "individual" : "common";
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
// 필드 타입 섹션의 필드들 처리
if (otherSection.type !== "table" && otherSection.fields) {
for (const field of otherSection.fields) {
// 필드별 오버라이드 확인
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = formData[field.columnName];
}
}
}
}
}
for (const item of sectionData) {
// 공통 필드 병합 + 개별 품목 데이터
const itemToSave = { ...commonFieldsData, ...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, config.saveConfig.sectionSaveModes, formData]);
// 다중 행 저장 (겸직 등)
const saveMultipleRows = useCallback(async () => {
@ -901,9 +1084,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,7 +1134,7 @@ export function UniversalFormModalComponent({
// 1. 메인 테이블 데이터 구성
const mainData: Record<string, any> = {};
config.sections.forEach((section) => {
if (section.repeatable) return; // 반복 섹션 제외
if (section.repeatable || section.type === "table") return; // 반복 섹션 및 테이블 타입 제외
(section.fields || []).forEach((field) => {
const value = formData[field.columnName];
if (value !== undefined && value !== null && value !== "") {
@ -962,9 +1145,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,7 +1237,7 @@ export function UniversalFormModalComponent({
// 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑
else {
config.sections.forEach((section) => {
if (section.repeatable) return;
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({
@ -1535,10 +1718,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 ? (

View File

@ -17,6 +17,7 @@ import {
Settings,
Database,
Layout,
Table,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@ -27,9 +28,11 @@ import {
FormSectionConfig,
FormFieldConfig,
MODAL_SIZE_OPTIONS,
SECTION_TYPE_OPTIONS,
} from "./types";
import {
defaultSectionConfig,
defaultTableSectionConfig,
generateSectionId,
} from "./config";
@ -37,6 +40,7 @@ import {
import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
import { SaveSettingsModal } from "./modals/SaveSettingsModal";
import { SectionLayoutModal } from "./modals/SectionLayoutModal";
import { TableSectionSettingsModal } from "./modals/TableSectionSettingsModal";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
@ -57,6 +61,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
const [saveSettingsModalOpen, setSaveSettingsModalOpen] = useState(false);
const [sectionLayoutModalOpen, setSectionLayoutModalOpen] = useState(false);
const [fieldDetailModalOpen, setFieldDetailModalOpen] = useState(false);
const [tableSectionSettingsModalOpen, setTableSectionSettingsModalOpen] = useState(false);
const [selectedSection, setSelectedSection] = useState<FormSectionConfig | null>(null);
const [selectedField, setSelectedField] = useState<FormFieldConfig | null>(null);
@ -95,23 +100,25 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const data = response.data?.data;
// API 응답 구조: { success, data: { columns: [...], total, page, ... } }
const columns = response.data?.data?.columns;
if (response.data?.success && Array.isArray(data)) {
if (response.data?.success && Array.isArray(columns)) {
setTableColumns((prev) => ({
...prev,
[tableName]: data.map(
[tableName]: columns.map(
(c: {
columnName?: string;
column_name?: string;
dataType?: string;
data_type?: string;
displayName?: string;
columnComment?: string;
column_comment?: string;
}) => ({
name: c.columnName || c.column_name || "",
type: c.dataType || c.data_type || "text",
label: c.columnComment || c.column_comment || c.columnName || c.column_name || "",
label: c.displayName || c.columnComment || c.column_comment || c.columnName || c.column_name || "",
}),
),
}));
@ -159,11 +166,14 @@ 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,
@ -171,6 +181,41 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
});
}, [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>) => {
onChange({
@ -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>
)}
{/* 레이아웃 설정 버튼 */}
<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" && 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>
)}
{/* 설정 버튼 (타입에 따라 다름) */}
{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,46 @@ 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}
allSections={config.sections as FormSectionConfig[]}
/>
)}
</div>
);
}

View File

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

View File

@ -11,9 +11,10 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Plus, Trash2, Database, Layers } from "lucide-react";
import { Plus, Trash2, Database, Layers, Info } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig } from "../types";
import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig, SectionSaveMode } from "../types";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
@ -219,19 +220,112 @@ 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;
};
const allFields = getAllFields();
// 섹션별 저장 방식 조회 (없으면 기본값 반환)
const getSectionSaveMode = (sectionId: string, sectionType: "fields" | "table"): "common" | "individual" => {
const sectionMode = localSaveConfig.sectionSaveModes?.find((s) => s.sectionId === sectionId);
if (sectionMode) {
return sectionMode.saveMode;
}
// 기본값: fields 타입은 공통 저장, table 타입은 개별 저장
return sectionType === "fields" ? "common" : "individual";
};
// 필드별 저장 방식 조회 (오버라이드 확인)
const getFieldSaveMode = (sectionId: string, fieldName: string, sectionType: "fields" | "table"): "common" | "individual" => {
const sectionMode = localSaveConfig.sectionSaveModes?.find((s) => s.sectionId === sectionId);
if (sectionMode) {
// 필드별 오버라이드 확인
const fieldOverride = sectionMode.fieldOverrides?.find((f) => f.fieldName === fieldName);
if (fieldOverride) {
return fieldOverride.saveMode;
}
return sectionMode.saveMode;
}
// 기본값
return sectionType === "fields" ? "common" : "individual";
};
// 섹션별 저장 방식 업데이트
const updateSectionSaveMode = (sectionId: string, mode: "common" | "individual") => {
const currentModes = localSaveConfig.sectionSaveModes || [];
const existingIndex = currentModes.findIndex((s) => s.sectionId === sectionId);
let newModes: SectionSaveMode[];
if (existingIndex >= 0) {
newModes = [...currentModes];
newModes[existingIndex] = { ...newModes[existingIndex], saveMode: mode };
} else {
newModes = [...currentModes, { sectionId, saveMode: mode }];
}
updateSaveConfig({ sectionSaveModes: newModes });
};
// 필드별 오버라이드 토글
const toggleFieldOverride = (sectionId: string, fieldName: string, sectionType: "fields" | "table") => {
const currentModes = localSaveConfig.sectionSaveModes || [];
const sectionIndex = currentModes.findIndex((s) => s.sectionId === sectionId);
// 섹션 설정이 없으면 먼저 생성
let newModes = [...currentModes];
if (sectionIndex < 0) {
const defaultMode = sectionType === "fields" ? "common" : "individual";
newModes.push({ sectionId, saveMode: defaultMode, fieldOverrides: [] });
}
const targetIndex = newModes.findIndex((s) => s.sectionId === sectionId);
const sectionMode = newModes[targetIndex];
const currentFieldOverrides = sectionMode.fieldOverrides || [];
const fieldOverrideIndex = currentFieldOverrides.findIndex((f) => f.fieldName === fieldName);
let newFieldOverrides;
if (fieldOverrideIndex >= 0) {
// 이미 오버라이드가 있으면 제거 (섹션 기본값으로 돌아감)
newFieldOverrides = currentFieldOverrides.filter((f) => f.fieldName !== fieldName);
} else {
// 오버라이드 추가 (섹션 기본값의 반대)
const oppositeMode = sectionMode.saveMode === "common" ? "individual" : "common";
newFieldOverrides = [...currentFieldOverrides, { fieldName, saveMode: oppositeMode }];
}
newModes[targetIndex] = { ...sectionMode, fieldOverrides: newFieldOverrides };
updateSaveConfig({ sectionSaveModes: newModes });
};
// 섹션의 필드 목록 가져오기
const getSectionFields = (section: FormSectionConfig): { fieldName: string; label: string }[] => {
if (section.type === "table" && section.tableConfig) {
// 테이블 타입: tableConfig.columns에서 필드 목록 가져오기
return (section.tableConfig.columns || []).map((col) => ({
fieldName: col.field,
label: col.label,
}));
} else if (section.fields) {
// 필드 타입: fields에서 목록 가져오기
return section.fields.map((field) => ({
fieldName: field.columnName,
label: field.label,
}));
}
return [];
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
@ -721,6 +815,150 @@ export function SaveSettingsModal({
</div>
)}
{/* 섹션별 저장 방식 */}
<div className="space-y-3 border rounded-lg p-3 bg-card">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-green-600" />
<h3 className="text-xs font-semibold"> </h3>
</div>
{/* 설명 */}
<div className="bg-muted/50 rounded-lg p-2.5 space-y-1.5">
<div className="flex items-start gap-2">
<Info className="h-3.5 w-3.5 text-blue-500 mt-0.5 shrink-0" />
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground">
<span className="font-medium text-foreground"> :</span> <span className="font-medium"></span>
<br />
<span className="text-[9px] text-muted-foreground/80">: 수주번호, , - 3 3 </span>
</p>
<p className="text-[10px] text-muted-foreground">
<span className="font-medium text-foreground"> :</span> <span className="font-medium"></span>
<br />
<span className="text-[9px] text-muted-foreground/80">: 품목코드, , - </span>
</p>
</div>
</div>
</div>
{/* 섹션 목록 */}
{sections.length === 0 ? (
<div className="text-center py-4 border border-dashed rounded-lg">
<p className="text-[10px] text-muted-foreground"> </p>
</div>
) : (
<Accordion type="multiple" className="space-y-2">
{sections.map((section) => {
const sectionType = section.type || "fields";
const currentMode = getSectionSaveMode(section.id, sectionType);
const sectionFields = getSectionFields(section);
return (
<AccordionItem
key={section.id}
value={section.id}
className={cn(
"border rounded-lg",
currentMode === "common" ? "bg-blue-50/30" : "bg-orange-50/30"
)}
>
<AccordionTrigger className="px-3 py-2 text-xs hover:no-underline">
<div className="flex items-center justify-between flex-1 mr-2">
<div className="flex items-center gap-2">
<span className="font-medium">{section.title}</span>
<Badge
variant="outline"
className={cn(
"text-[8px] h-4",
sectionType === "table" ? "border-orange-300 text-orange-600" : "border-blue-300 text-blue-600"
)}
>
{sectionType === "table" ? "테이블" : "필드"}
</Badge>
</div>
<Badge
variant={currentMode === "common" ? "default" : "secondary"}
className="text-[8px] h-4"
>
{currentMode === "common" ? "공통 저장" : "개별 저장"}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 space-y-3">
{/* 저장 방식 선택 */}
<div className="space-y-2">
<Label className="text-[10px] font-medium"> </Label>
<RadioGroup
value={currentMode}
onValueChange={(value) => updateSectionSaveMode(section.id, value as "common" | "individual")}
className="flex gap-4"
>
<div className="flex items-center space-x-1.5">
<RadioGroupItem value="common" id={`${section.id}-common`} className="h-3 w-3" />
<Label htmlFor={`${section.id}-common`} className="text-[10px] cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-1.5">
<RadioGroupItem value="individual" id={`${section.id}-individual`} className="h-3 w-3" />
<Label htmlFor={`${section.id}-individual`} className="text-[10px] cursor-pointer">
</Label>
</div>
</RadioGroup>
</div>
{/* 필드 목록 */}
{sectionFields.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<Label className="text-[10px] font-medium"> ({sectionFields.length})</Label>
<HelpText> </HelpText>
<div className="grid grid-cols-2 gap-1.5">
{sectionFields.map((field) => {
const fieldMode = getFieldSaveMode(section.id, field.fieldName, sectionType);
const isOverridden = fieldMode !== currentMode;
return (
<button
key={field.fieldName}
onClick={() => toggleFieldOverride(section.id, field.fieldName, sectionType)}
className={cn(
"flex items-center justify-between px-2 py-1.5 rounded border text-left transition-colors",
isOverridden
? "border-amber-300 bg-amber-50"
: "border-gray-200 bg-white hover:bg-gray-50"
)}
>
<span className="text-[9px] truncate flex-1">
{field.label}
<span className="text-muted-foreground ml-1">({field.fieldName})</span>
</span>
<Badge
variant={fieldMode === "common" ? "default" : "secondary"}
className={cn(
"text-[7px] h-3.5 ml-1 shrink-0",
isOverridden && "ring-1 ring-amber-400"
)}
>
{fieldMode === "common" ? "공통" : "개별"}
</Badge>
</button>
);
})}
</div>
</div>
</>
)}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
</div>
{/* 저장 후 동작 */}
<div className="space-y-2 border rounded-lg p-3 bg-card">
<h3 className="text-xs font-semibold"> </h3>

View File

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

View File

@ -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,294 @@ 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[];
// 조회 설정 (동적 값 조회)
lookup?: LookupConfig;
// 날짜 일괄 적용 (type이 "date"일 때만 사용)
// 활성화 시 첫 번째 날짜 입력 시 모든 행에 동일한 날짜가 자동 적용됨
batchApply?: boolean;
}
// ============================================
// 조회(Lookup) 설정 관련 타입 정의
// ============================================
/**
*
* - sameTable: 동일 ( )
* - relatedTable: 연관 ( )
* - combinedLookup: 복합 ( + )
*/
export type LookupType = "sameTable" | "relatedTable" | "combinedLookup";
/**
*
* : 거래처
*/
export interface LookupTransform {
enabled: boolean; // 변환 사용 여부
tableName: string; // 변환 테이블 (예: customer_mng)
matchColumn: string; // 찾을 컬럼 (예: customer_name)
resultColumn: string; // 가져올 컬럼 (예: customer_code)
}
/**
*
* ( )
*/
export interface ExternalTableLookup {
tableName: string; // 조회할 테이블
matchColumn: string; // 조회 조건 컬럼 (WHERE 절에서 비교할 컬럼)
matchSourceType: "currentRow" | "sourceTable" | "sectionField"; // 비교값 출처
matchSourceField: string; // 비교값 필드명
matchSectionId?: string; // sectionField인 경우 섹션 ID
resultColumn: string; // 가져올 컬럼 (SELECT 절)
}
/**
*
*
* sourceType :
* - "currentRow": (rowData에서 , : part_code, quantity)
* - "sourceTable": (_sourceData에서 , : item_number, company_code)
* - "sectionField": (formData에서 , : partner_id)
* - "externalTable": ( )
*/
export interface LookupCondition {
sourceType: "currentRow" | "sourceTable" | "sectionField" | "externalTable"; // 값 출처
sourceField: string; // 출처의 필드명 (참조할 필드)
sectionId?: string; // sectionField인 경우 섹션 ID
targetColumn: string; // 조회 테이블의 컬럼
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
externalLookup?: ExternalTableLookup;
// 값 변환 설정 (선택) - 이름→코드 등 변환이 필요할 때 (레거시 호환)
transform?: LookupTransform;
}
/**
*
*
*/
export interface LookupOption {
id: string; // 옵션 고유 ID
label: string; // 옵션 라벨 (예: "기준단가", "거래처별 단가")
displayLabel?: string; // 헤더 드롭다운에 표시될 텍스트 (예: "기준단가" → "단가 (기준단가)")
type: LookupType; // 조회 유형
// 조회 테이블 설정
tableName: string; // 조회할 테이블
valueColumn: string; // 가져올 컬럼
// 조회 조건 (여러 조건 AND로 결합)
conditions: LookupCondition[];
// 기본 옵션 여부
isDefault?: boolean;
}
/**
*
*/
export interface LookupConfig {
enabled: boolean; // 조회 사용 여부
options: LookupOption[]; // 조회 옵션 목록
defaultOptionId?: string; // 기본 선택 옵션 ID
}
/**
*
*
*/
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의 필드명
}
/**
*
*
*
* sourceType :
* - "row": (rowData)
* - "sourceData": (_sourceData)
* - "formData": (formData)
* - "externalTable":
*/
export interface TableJoinCondition {
sourceType: "row" | "sourceData" | "formData" | "externalTable"; // 값 출처
sourceField: string; // 출처의 필드명
targetColumn: string; // 조회 테이블의 컬럼
operator?: "=" | "!=" | ">" | "<" | ">=" | "<="; // 연산자 (기본: "=")
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
externalLookup?: ExternalTableLookup;
// 값 변환 설정 (선택) - 이름→코드 등 중간 변환이 필요할 때 (레거시 호환)
transform?: {
tableName: string; // 변환 테이블 (예: customer_mng)
matchColumn: string; // 찾을 컬럼 (예: customer_name)
resultColumn: string; // 가져올 컬럼 (예: customer_code)
};
}
/**
*
*
*/
export interface ColumnModeConfig {
id: string; // 모드 고유 ID
label: string; // 모드 라벨 (예: "기준 단가", "거래처별 단가")
isDefault?: boolean; // 기본 모드 여부
valueMapping: ValueMappingConfig; // 이 모드의 값 매핑
}
/**
*
*
*/
export interface TableCalculationRule {
resultField: string; // 결과를 저장할 필드
formula: string; // 계산 공식 (예: "quantity * unit_price")
dependencies: string[]; // 의존하는 필드들
}
// 다중 행 저장 설정
@ -214,6 +507,21 @@ export interface MultiRowSaveConfig {
mainSectionFields?: string[]; // 메인 행에만 저장할 필드
}
/**
*
* 저장: 해당 (: 수주번호, )
* 저장: 해당 (: 품목코드, , )
*/
export interface SectionSaveMode {
sectionId: string;
saveMode: "common" | "individual"; // 공통 저장 / 개별 저장
// 필드별 세부 설정 (선택사항 - 섹션 기본값과 다르게 설정할 필드)
fieldOverrides?: {
fieldName: string;
saveMode: "common" | "individual";
}[];
}
// 저장 설정
export interface SaveConfig {
tableName: string;
@ -225,6 +533,9 @@ export interface SaveConfig {
// 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용)
customApiSave?: CustomApiSaveConfig;
// 섹션별 저장 방식 설정
sectionSaveModes?: SectionSaveMode[];
// 저장 후 동작 (간편 설정)
showToast?: boolean; // 토스트 메시지 (기본: true)
refreshParent?: boolean; // 부모 새로고침 (기본: true)
@ -432,3 +743,69 @@ 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;
// 조회 유형 옵션
export const LOOKUP_TYPE_OPTIONS = [
{ value: "sameTable", label: "동일 테이블 조회" },
{ value: "relatedTable", label: "연관 테이블 조회" },
{ value: "combinedLookup", label: "복합 조건 조회" },
] as const;
// 조회 조건 소스 타입 옵션
export const LOOKUP_CONDITION_SOURCE_OPTIONS = [
{ value: "currentRow", label: "현재 행" },
{ value: "sourceTable", label: "소스 테이블" },
{ value: "sectionField", label: "다른 섹션" },
{ value: "externalTable", label: "외부 테이블" },
] as const;

View File

@ -675,6 +675,14 @@ export class ButtonActionExecutor {
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
}
// 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리
// 범용_폼_모달 내부에 _tableSection_ 데이터가 있는 경우 공통 필드 + 개별 품목 병합 저장
const universalFormModalResult = await this.handleUniversalFormModalTableSectionSave(config, context, formData);
if (universalFormModalResult.handled) {
console.log("✅ [handleSave] Universal Form Modal 테이블 섹션 저장 완료");
return universalFormModalResult.success;
}
// 폼 유효성 검사
if (config.validateForm) {
const validation = this.validateFormData(formData);
@ -1479,6 +1487,244 @@ export class ButtonActionExecutor {
}
}
/**
* 🆕 Universal Form Modal
* _폼_모달 + _tableSection_
* 모드: INSERT/UPDATE/DELETE
*/
private static async handleUniversalFormModalTableSectionSave(
config: ButtonActionConfig,
context: ButtonActionContext,
formData: Record<string, any>,
): Promise<{ handled: boolean; success: boolean }> {
const { tableName, screenId } = context;
// 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음)
const universalFormModalKey = Object.keys(formData).find((key) => {
const value = formData[key];
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
// _tableSection_ 키가 있는지 확인
return Object.keys(value).some((k) => k.startsWith("_tableSection_"));
});
if (!universalFormModalKey) {
return { handled: false, success: false };
}
console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey);
const modalData = formData[universalFormModalKey];
// _tableSection_ 데이터 추출
const tableSectionData: Record<string, any[]> = {};
const commonFieldsData: Record<string, any> = {};
// 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용)
// modalData 내부 또는 최상위 formData에서 찾음
const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || [];
for (const [key, value] of Object.entries(modalData)) {
if (key.startsWith("_tableSection_")) {
const sectionId = key.replace("_tableSection_", "");
tableSectionData[sectionId] = value as any[];
} else if (!key.startsWith("_")) {
// _로 시작하지 않는 필드는 공통 필드로 처리
commonFieldsData[key] = value;
}
}
console.log("🎯 [handleUniversalFormModalTableSectionSave] 데이터 분리:", {
commonFields: Object.keys(commonFieldsData),
tableSections: Object.keys(tableSectionData),
tableSectionCounts: Object.entries(tableSectionData).map(([k, v]) => ({ [k]: v.length })),
originalGroupedDataCount: originalGroupedData.length,
isEditMode: originalGroupedData.length > 0,
});
// 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음
const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0);
if (!hasTableSectionData && originalGroupedData.length === 0) {
console.log("⚠️ [handleUniversalFormModalTableSectionSave] 테이블 섹션 데이터 없음 - 일반 저장으로 전환");
return { handled: false, success: false };
}
try {
// 사용자 정보 추가
if (!context.userId) {
throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요.");
}
const userInfo = {
writer: context.userId,
created_by: context.userId,
updated_by: context.userId,
company_code: context.companyCode || "",
};
let insertedCount = 0;
let updatedCount = 0;
let deletedCount = 0;
// 각 테이블 섹션 처리
for (const [sectionId, currentItems] of Object.entries(tableSectionData)) {
console.log(`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`);
// 1⃣ 신규 품목 INSERT (id가 없는 항목)
const newItems = currentItems.filter((item) => !item.id);
for (const item of newItems) {
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
// 내부 메타데이터 제거
Object.keys(rowToSave).forEach((key) => {
if (key.startsWith("_")) {
delete rowToSave[key];
}
});
console.log(" [INSERT] 신규 품목:", rowToSave);
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
data: rowToSave,
});
if (!saveResult.success) {
throw new Error(saveResult.message || "신규 품목 저장 실패");
}
insertedCount++;
}
// 2⃣ 기존 품목 UPDATE (id가 있는 항목, 변경된 경우만)
const existingItems = currentItems.filter((item) => item.id);
for (const item of existingItems) {
const originalItem = originalGroupedData.find((orig) => orig.id === item.id);
if (!originalItem) {
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - INSERT로 처리: id=${item.id}`);
// 원본이 없으면 신규로 처리
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
Object.keys(rowToSave).forEach((key) => {
if (key.startsWith("_")) {
delete rowToSave[key];
}
});
delete rowToSave.id; // id 제거하여 INSERT
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
data: rowToSave,
});
if (!saveResult.success) {
throw new Error(saveResult.message || "품목 저장 실패");
}
insertedCount++;
continue;
}
// 변경 사항 확인 (공통 필드 포함)
const currentDataWithCommon = { ...commonFieldsData, ...item };
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
if (hasChanges) {
console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}`);
// 변경된 필드만 추출하여 부분 업데이트
const updateResult = await DynamicFormApi.updateFormDataPartial(
item.id,
originalItem,
currentDataWithCommon,
tableName!,
);
if (!updateResult.success) {
throw new Error(updateResult.message || "품목 수정 실패");
}
updatedCount++;
} else {
console.log(`⏭️ [SKIP] 변경 없음: id=${item.id}`);
}
}
// 3⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
const currentIds = new Set(currentItems.map((item) => item.id).filter(Boolean));
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id));
for (const deletedItem of deletedItems) {
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}`);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(tableName!, deletedItem.id);
if (!deleteResult.success) {
throw new Error(deleteResult.message || "품목 삭제 실패");
}
deletedCount++;
}
}
// 결과 메시지 생성
const resultParts: string[] = [];
if (insertedCount > 0) resultParts.push(`${insertedCount}개 추가`);
if (updatedCount > 0) resultParts.push(`${updatedCount}개 수정`);
if (deletedCount > 0) resultParts.push(`${deletedCount}개 삭제`);
const resultMessage = resultParts.length > 0 ? resultParts.join(", ") : "변경 사항 없음";
console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`);
toast.success(`저장 완료: ${resultMessage}`);
// 저장 성공 이벤트 발생
window.dispatchEvent(new CustomEvent("saveSuccess"));
window.dispatchEvent(new CustomEvent("refreshTable"));
// EditModal 닫기 이벤트 발생
window.dispatchEvent(new CustomEvent("closeEditModal"));
return { handled: true, success: true };
} catch (error: any) {
console.error("❌ [handleUniversalFormModalTableSectionSave] 저장 오류:", error);
toast.error(error.message || "저장 중 오류가 발생했습니다.");
return { handled: true, success: false };
}
}
/**
*
*/
private static checkForChanges(original: Record<string, any>, current: Record<string, any>): boolean {
// 비교할 필드 목록 (메타데이터 제외)
const fieldsToCompare = new Set([
...Object.keys(original).filter((k) => !k.startsWith("_")),
...Object.keys(current).filter((k) => !k.startsWith("_")),
]);
for (const field of fieldsToCompare) {
// 시스템 필드는 비교에서 제외
if (["created_date", "updated_date", "created_by", "updated_by", "writer"].includes(field)) {
continue;
}
const originalValue = original[field];
const currentValue = current[field];
// null/undefined 통일 처리
const normalizedOriginal = originalValue === null || originalValue === undefined ? "" : String(originalValue);
const normalizedCurrent = currentValue === null || currentValue === undefined ? "" : String(currentValue);
if (normalizedOriginal !== normalizedCurrent) {
console.log(` 📝 변경 감지: ${field} = "${normalizedOriginal}" → "${normalizedCurrent}"`);
return true;
}
}
return false;
}
/**
* 🆕 (SelectedItemsDetailInput용 - )
* ItemData[] details