Merge branch 'main' into feature/screen-management
This commit is contained in:
commit
43a6fb675f
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -205,6 +205,9 @@ export function ModalRepeaterTableComponent({
|
|||
const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true;
|
||||
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
|
||||
|
||||
// 모달 필터 설정
|
||||
const modalFilters = componentConfig?.modalFilters || [];
|
||||
|
||||
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
|
||||
const columnName = component?.columnName;
|
||||
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
||||
|
|
@ -889,6 +892,7 @@ export function ModalRepeaterTableComponent({
|
|||
uniqueField={uniqueField}
|
||||
onSelect={handleAddItems}
|
||||
columnLabels={columnLabels}
|
||||
modalFilters={modalFilters}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep } from "./types";
|
||||
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep, ModalFilterConfig } from "./types";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -842,6 +842,97 @@ export function ModalRepeaterTableConfigPanel({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 필터 설정 */}
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">모달 필터</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const filters = localConfig.modalFilters || [];
|
||||
updateConfig({
|
||||
modalFilters: [...filters, { column: "", label: "", type: "select" }],
|
||||
});
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.sourceTable}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
필터 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
모달에서 드롭다운으로 필터링할 컬럼을 설정합니다. 소스 테이블의 해당 컬럼에서 고유 값들이 자동으로 표시됩니다.
|
||||
</p>
|
||||
{(localConfig.modalFilters || []).length > 0 && (
|
||||
<div className="space-y-2 mt-2">
|
||||
{(localConfig.modalFilters || []).map((filter, index) => (
|
||||
<div key={index} className="flex items-center gap-2 p-2 border rounded-md bg-muted/30">
|
||||
<Select
|
||||
value={filter.column}
|
||||
onValueChange={(value) => {
|
||||
const filters = [...(localConfig.modalFilters || [])];
|
||||
filters[index] = { ...filters[index], column: value };
|
||||
updateConfig({ modalFilters: filters });
|
||||
}}
|
||||
disabled={!localConfig.sourceTable || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-[140px]">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceTableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={filter.label}
|
||||
onChange={(e) => {
|
||||
const filters = [...(localConfig.modalFilters || [])];
|
||||
filters[index] = { ...filters[index], label: e.target.value };
|
||||
updateConfig({ modalFilters: filters });
|
||||
}}
|
||||
placeholder="라벨"
|
||||
className="h-8 text-xs w-[100px]"
|
||||
/>
|
||||
<Select
|
||||
value={filter.type}
|
||||
onValueChange={(value: "select" | "text") => {
|
||||
const filters = [...(localConfig.modalFilters || [])];
|
||||
filters[index] = { ...filters[index], type: value };
|
||||
updateConfig({ modalFilters: filters });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="select">드롭다운</SelectItem>
|
||||
<SelectItem value="text">텍스트</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const filters = [...(localConfig.modalFilters || [])];
|
||||
filters.splice(index, 1);
|
||||
updateConfig({ modalFilters: filters });
|
||||
}}
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 반복 테이블 컬럼 관리 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -184,7 +184,12 @@ export interface FormSectionConfig {
|
|||
description?: string;
|
||||
collapsible?: boolean; // 접을 수 있는지 (기본: false)
|
||||
defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false)
|
||||
fields: FormFieldConfig[];
|
||||
|
||||
// 섹션 타입: fields (기본) 또는 table (테이블 형식)
|
||||
type?: "fields" | "table";
|
||||
|
||||
// type: "fields" 일 때 사용
|
||||
fields?: FormFieldConfig[];
|
||||
|
||||
// 반복 섹션 (겸직 등)
|
||||
repeatable?: boolean;
|
||||
|
|
@ -199,6 +204,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;
|
||||
|
|
|
|||
|
|
@ -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 배열을 개별 레코드로 저장
|
||||
|
|
|
|||
Loading…
Reference in New Issue