Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map

This commit is contained in:
dohyeons 2025-12-04 19:48:34 +09:00
commit 58ca340699
33 changed files with 4952 additions and 528 deletions

View File

@ -29,6 +29,7 @@ export class EntityJoinController {
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams
} = req.query;
@ -125,6 +126,19 @@ export class EntityJoinController {
}
}
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
let parsedExcludeFilter: any = undefined;
if (excludeFilter) {
try {
parsedExcludeFilter =
typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter;
logger.info("제외 필터 파싱 완료:", parsedExcludeFilter);
} catch (error) {
logger.warn("제외 필터 파싱 오류:", error);
parsedExcludeFilter = undefined;
}
}
const result = await tableManagementService.getTableDataWithEntityJoins(
tableName,
{
@ -141,6 +155,7 @@ export class EntityJoinController {
additionalJoinColumns: parsedAdditionalJoinColumns,
screenEntityConfigs: parsedScreenEntityConfigs,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
}
);

View File

@ -2462,6 +2462,14 @@ export class TableManagementService {
}>;
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
dataFilter?: any; // 🆕 데이터 필터
excludeFilter?: {
enabled: boolean;
referenceTable: string;
referenceColumn: string;
sourceColumn: string;
filterColumn?: string;
filterValue?: any;
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
}
): Promise<EntityJoinResponse> {
const startTime = Date.now();
@ -2716,6 +2724,44 @@ export class TableManagementService {
}
}
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
if (options.excludeFilter && options.excludeFilter.enabled) {
const {
referenceTable,
referenceColumn,
sourceColumn,
filterColumn,
filterValue,
} = options.excludeFilter;
if (referenceTable && referenceColumn && sourceColumn) {
// 서브쿼리로 이미 존재하는 데이터 제외
let excludeSubquery = `main."${sourceColumn}" NOT IN (
SELECT "${referenceColumn}" FROM "${referenceTable}"
WHERE "${referenceColumn}" IS NOT NULL`;
// 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외)
if (filterColumn && filterValue !== undefined && filterValue !== null) {
excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`;
}
excludeSubquery += ")";
whereClause = whereClause
? `${whereClause} AND ${excludeSubquery}`
: excludeSubquery;
logger.info(`🚫 제외 필터 적용 (Entity 조인):`, {
referenceTable,
referenceColumn,
sourceColumn,
filterColumn,
filterValue,
excludeSubquery,
});
}
}
// ORDER BY 절 구성
const orderBy = options.sortBy
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`

View File

@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import {
@ -47,6 +47,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const [rightTitle, setRightTitle] = useState("규칙 편집");
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
const [customSeparator, setCustomSeparator] = useState("");
useEffect(() => {
loadRules();
@ -87,6 +91,50 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
}
}, [currentRule, onChange]);
// currentRule이 변경될 때 구분자 상태 동기화
useEffect(() => {
if (currentRule) {
const sep = currentRule.separator ?? "-";
// 빈 문자열이면 "none"
if (sep === "") {
setSeparatorType("none");
setCustomSeparator("");
return;
}
// 미리 정의된 구분자인지 확인 (none, custom 제외)
const predefinedOption = SEPARATOR_OPTIONS.find(
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
);
if (predefinedOption) {
setSeparatorType(predefinedOption.value);
setCustomSeparator("");
} else {
// 직접 입력된 구분자
setSeparatorType("custom");
setCustomSeparator(sep);
}
}
}, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
// 구분자 변경 핸들러
const handleSeparatorChange = useCallback((type: SeparatorType) => {
setSeparatorType(type);
if (type !== "custom") {
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
const newSeparator = option?.displayValue ?? "";
setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null);
setCustomSeparator("");
}
}, []);
// 직접 입력 구분자 변경 핸들러
const handleCustomSeparatorChange = useCallback((value: string) => {
// 최대 2자 제한
const trimmedValue = value.slice(0, 2);
setCustomSeparator(trimmedValue);
setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null);
}, []);
const handleAddPart = useCallback(() => {
if (!currentRule) return;
@ -373,7 +421,44 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div>
</div>
{/* 두 번째 줄: 자동 감지된 테이블 정보 표시 */}
{/* 두 번째 줄: 구분자 설정 */}
<div className="flex items-end gap-3">
<div className="w-48 space-y-2">
<Label className="text-sm font-medium"></Label>
<Select
value={separatorType}
onValueChange={(value) => handleSeparatorChange(value as SeparatorType)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="구분자 선택" />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{separatorType === "custom" && (
<div className="w-32 space-y-2">
<Label className="text-sm font-medium"> </Label>
<Input
value={customSeparator}
onChange={(e) => handleCustomSeparatorChange(e.target.value)}
className="h-9"
placeholder="최대 2자"
maxLength={2}
/>
</div>
)}
<p className="text-muted-foreground pb-2 text-xs">
</p>
</div>
{/* 세 번째 줄: 자동 감지된 테이블 정보 표시 */}
{currentTableName && (
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>

View File

@ -304,7 +304,24 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
};
// 저장 버튼 클릭 시 - UPDATE 액션 실행
const handleSave = async () => {
const handleSave = async (saveData?: any) => {
// universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵
if (saveData?._saveCompleted) {
console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
handleClose();
return;
}
if (!screenData?.screenInfo?.tableName) {
toast.error("테이블 정보가 없습니다.");
return;

View File

@ -69,6 +69,14 @@ export const entityJoinApi = {
}>;
screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정
dataFilter?: any; // 🆕 데이터 필터
excludeFilter?: {
enabled: boolean;
referenceTable: string;
referenceColumn: string;
sourceColumn: string;
filterColumn?: string;
filterValue?: any;
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
} = {},
): Promise<EntityJoinResponse> => {
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
@ -90,6 +98,7 @@ export const entityJoinApi = {
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
},
});
return response.data.data;

View File

@ -170,8 +170,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}
};
// 🆕 disabledFields 체크
const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).readonly;
// 🆕 disabledFields 체크 + readonly 체크
const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).disabled;
const isFieldReadonly = (component as any).readonly || (component as any).componentConfig?.readonly;
return (
<CategorySelectComponent
@ -182,6 +183,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
placeholder={component.componentConfig?.placeholder || "선택하세요"}
required={(component as any).required}
disabled={isFieldDisabled}
readonly={isFieldReadonly}
className="w-full"
/>
);

View File

@ -42,10 +42,26 @@ export function AutocompleteSearchInputComponent({
// config prop 우선, 없으면 개별 prop 사용
const tableName = config?.tableName || propTableName || "";
const displayField = config?.displayField || propDisplayField || "";
const displayFields = config?.displayFields || (displayField ? [displayField] : []); // 다중 표시 필드
const displaySeparator = config?.displaySeparator || " → "; // 구분자
const valueField = config?.valueField || propValueField || "";
const searchFields = config?.searchFields || propSearchFields || [displayField];
const searchFields = config?.searchFields || propSearchFields || displayFields; // 검색 필드도 다중 표시 필드 사용
const placeholder = config?.placeholder || propPlaceholder || "검색...";
// 다중 필드 값을 조합하여 표시 문자열 생성
const getDisplayValue = (item: EntitySearchResult): string => {
if (displayFields.length > 1) {
// 여러 필드를 구분자로 조합
const values = displayFields
.map((field) => item[field])
.filter((v) => v !== null && v !== undefined && v !== "")
.map((v) => String(v));
return values.join(displaySeparator);
}
// 단일 필드
return item[displayField] || "";
};
const [inputValue, setInputValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
@ -115,7 +131,7 @@ export function AutocompleteSearchInputComponent({
const handleSelect = (item: EntitySearchResult) => {
setSelectedData(item);
setInputValue(item[displayField] || "");
setInputValue(getDisplayValue(item));
console.log("🔍 AutocompleteSearchInput handleSelect:", {
item,
@ -239,7 +255,7 @@ export function AutocompleteSearchInputComponent({
onClick={() => handleSelect(item)}
className="w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent sm:text-sm"
>
<div className="font-medium">{item[displayField]}</div>
<div className="font-medium">{getDisplayValue(item)}</div>
</button>
))}
</div>

View File

@ -184,52 +184,118 @@ export function AutocompleteSearchInputConfigPanel({
</Popover>
</div>
{/* 2. 표시 필드 선택 */}
{/* 2. 표시 필드 선택 (다중 선택 가능) */}
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm">2. *</Label>
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDisplayFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingSourceColumns}
>
{localConfig.displayField
? sourceTableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
: isLoadingSourceColumns ? "로딩 중..." : "사용자에게 보여줄 필드"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{sourceTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
updateConfig({ displayField: column.columnName });
setOpenDisplayFieldCombo(false);
<Label className="text-xs font-semibold sm:text-sm">2. * ( )</Label>
<div className="space-y-2">
{/* 선택된 필드 표시 */}
{(localConfig.displayFields && localConfig.displayFields.length > 0) ? (
<div className="flex flex-wrap gap-1 rounded-md border p-2 min-h-[40px]">
{localConfig.displayFields.map((fieldName) => {
const col = sourceTableColumns.find((c) => c.columnName === fieldName);
return (
<span
key={fieldName}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs"
>
{col?.displayName || fieldName}
<button
type="button"
onClick={() => {
const newFields = localConfig.displayFields?.filter((f) => f !== fieldName) || [];
updateConfig({
displayFields: newFields,
displayField: newFields[0] || "", // 첫 번째 필드를 기본 displayField로
});
}}
className="text-xs sm:text-sm"
className="hover:text-destructive"
>
<Check className={cn("mr-2 h-4 w-4", localConfig.displayField === column.columnName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<X className="h-3 w-3" />
</button>
</span>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed p-2 text-center text-xs text-muted-foreground">
</div>
)}
{/* 필드 선택 드롭다운 */}
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDisplayFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingSourceColumns}
>
{isLoadingSourceColumns ? "로딩 중..." : "필드 추가..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{sourceTableColumns.map((column) => {
const isSelected = localConfig.displayFields?.includes(column.columnName);
return (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
const currentFields = localConfig.displayFields || [];
let newFields: string[];
if (isSelected) {
newFields = currentFields.filter((f) => f !== column.columnName);
} else {
newFields = [...currentFields, column.columnName];
}
updateConfig({
displayFields: newFields,
displayField: newFields[0] || "", // 첫 번째 필드를 기본 displayField로
});
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", isSelected ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 구분자 설정 */}
{localConfig.displayFields && localConfig.displayFields.length > 1 && (
<div className="flex items-center gap-2">
<Label className="text-xs whitespace-nowrap">:</Label>
<Input
value={localConfig.displaySeparator || " → "}
onChange={(e) => updateConfig({ displaySeparator: e.target.value })}
placeholder=" → "
className="h-7 w-20 text-xs text-center"
/>
<span className="text-xs text-muted-foreground">
: {localConfig.displayFields.map((f) => {
const col = sourceTableColumns.find((c) => c.columnName === f);
return col?.displayName || f;
}).join(localConfig.displaySeparator || " → ")}
</span>
</div>
)}
</div>
</div>
{/* 3. 저장 대상 테이블 선택 */}
@ -419,7 +485,9 @@ export function AutocompleteSearchInputConfigPanel({
<strong> :</strong> {localConfig.tableName}
</p>
<p>
<strong> :</strong> {localConfig.displayField}
<strong> :</strong> {localConfig.displayFields?.length
? localConfig.displayFields.join(localConfig.displaySeparator || " → ")
: localConfig.displayField}
</p>
<p>
<strong> :</strong> {localConfig.targetTable}

View File

@ -29,5 +29,8 @@ export interface AutocompleteSearchInputConfig {
fieldMappings?: FieldMapping[]; // 매핑할 필드 목록
// 저장 대상 테이블 (간소화 버전)
targetTable?: string;
// 🆕 다중 표시 필드 설정 (여러 컬럼 조합)
displayFields?: string[]; // 여러 컬럼을 조합하여 표시
displaySeparator?: string; // 구분자 (기본값: " - ")
}

View File

@ -663,9 +663,29 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
return;
}
// 🆕 modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
let effectiveSelectedRowsData = selectedRowsData;
if ((!selectedRowsData || selectedRowsData.length === 0) && effectiveTableName) {
try {
const { useModalDataStore } = await import("@/stores/modalDataStore");
const dataRegistry = useModalDataStore.getState().dataRegistry;
const modalData = dataRegistry[effectiveTableName];
if (modalData && modalData.length > 0) {
effectiveSelectedRowsData = modalData;
console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", {
tableName: effectiveTableName,
count: modalData.length,
data: modalData,
});
}
} catch (error) {
console.warn("modalDataStore 접근 실패:", error);
}
}
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
const hasDataToDelete =
(selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
toast.warning("삭제할 항목을 먼저 선택해주세요.");
@ -724,9 +744,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
onClose,
onFlowRefresh, // 플로우 새로고침 콜백 추가
onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출)
// 테이블 선택된 행 정보 추가
// 테이블 선택된 행 정보 추가 (modalDataStore에서 가져온 데이터 우선)
selectedRows,
selectedRowsData,
selectedRowsData: effectiveSelectedRowsData,
// 테이블 정렬 정보 추가
sortBy, // 🆕 정렬 컬럼
sortOrder, // 🆕 정렬 방향

View File

@ -74,6 +74,9 @@ import "./location-swap-selector/LocationSwapSelectorRenderer";
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
// 🆕 범용 폼 모달 컴포넌트
import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
/**
*
*/

View File

@ -193,7 +193,18 @@ export function ModalRepeaterTableComponent({
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
const columnName = component?.columnName;
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// 🆕 내부 상태로 데이터 관리 (즉시 UI 반영을 위해)
const [localValue, setLocalValue] = useState<any[]>(externalValue);
// 🆕 외부 값(formData, propValue) 변경 시 내부 상태 동기화
useEffect(() => {
// 외부 값이 변경되었고, 내부 값과 다른 경우에만 동기화
if (JSON.stringify(externalValue) !== JSON.stringify(localValue)) {
setLocalValue(externalValue);
}
}, [externalValue]);
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용)
const handleChange = (newData: any[]) => {
@ -249,6 +260,9 @@ export function ModalRepeaterTableComponent({
}
}
// 🆕 내부 상태 즉시 업데이트 (UI 즉시 반영) - 일괄 적용된 데이터로 업데이트
setLocalValue(processedData);
// 기존 onChange 콜백 호출 (호환성)
const externalOnChange = componentConfig?.onChange || propOnChange;
if (externalOnChange) {
@ -321,7 +335,7 @@ export function ModalRepeaterTableComponent({
const handleSaveRequest = async (event: Event) => {
const componentKey = columnName || component?.id || "modal_repeater_data";
if (value.length === 0) {
if (localValue.length === 0) {
console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음");
return;
}
@ -332,7 +346,7 @@ export function ModalRepeaterTableComponent({
.filter(col => col.mapping?.type === "source" && col.mapping?.sourceField)
.map(col => col.field);
const filteredData = value.map((item: any) => {
const filteredData = localValue.map((item: any) => {
const filtered: Record<string, any> = {};
Object.keys(item).forEach((key) => {
@ -389,16 +403,16 @@ export function ModalRepeaterTableComponent({
return () => {
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
};
}, [value, columnName, component?.id, onFormDataChange, targetTable]);
}, [localValue, columnName, component?.id, onFormDataChange, targetTable]);
const { calculateRow, calculateAll } = useCalculation(calculationRules);
// 초기 데이터에 계산 필드 적용
useEffect(() => {
if (value.length > 0 && calculationRules.length > 0) {
const calculated = calculateAll(value);
if (localValue.length > 0 && calculationRules.length > 0) {
const calculated = calculateAll(localValue);
// 값이 실제로 변경된 경우만 업데이트
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
if (JSON.stringify(calculated) !== JSON.stringify(localValue)) {
handleChange(calculated);
}
}
@ -506,7 +520,7 @@ export function ModalRepeaterTableComponent({
const calculatedItems = calculateAll(mappedItems);
// 기존 데이터에 추가
const newData = [...value, ...calculatedItems];
const newData = [...localValue, ...calculatedItems];
console.log("✅ 최종 데이터:", newData.length, "개 항목");
// ✅ 통합 onChange 호출 (formData 반영 포함)
@ -518,7 +532,7 @@ export function ModalRepeaterTableComponent({
const calculatedRow = calculateRow(newRow);
// 데이터 업데이트
const newData = [...value];
const newData = [...localValue];
newData[index] = calculatedRow;
// ✅ 통합 onChange 호출 (formData 반영 포함)
@ -526,7 +540,7 @@ export function ModalRepeaterTableComponent({
};
const handleRowDelete = (index: number) => {
const newData = value.filter((_, i) => i !== index);
const newData = localValue.filter((_, i) => i !== index);
// ✅ 통합 onChange 호출 (formData 반영 포함)
handleChange(newData);
@ -543,7 +557,7 @@ export function ModalRepeaterTableComponent({
{/* 추가 버튼 */}
<div className="flex justify-between items-center">
<div className="text-sm text-muted-foreground">
{value.length > 0 && `${value.length}개 항목`}
{localValue.length > 0 && `${localValue.length}개 항목`}
</div>
<Button
onClick={() => setModalOpen(true)}
@ -557,7 +571,7 @@ export function ModalRepeaterTableComponent({
{/* Repeater 테이블 */}
<RepeaterTable
columns={columns}
data={value}
data={localValue}
onDataChange={handleChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
@ -573,7 +587,7 @@ export function ModalRepeaterTableComponent({
multiSelect={multiSelect}
filterCondition={filterCondition}
modalTitle={modalTitle}
alreadySelected={value}
alreadySelected={localValue}
uniqueField={uniqueField}
onSelect={handleAddItems}
columnLabels={columnLabels}

View File

@ -52,6 +52,10 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
menuObjid, // 🆕 메뉴 OBJID
...props
}) => {
// 🆕 읽기전용/비활성화 상태 확인
const isReadonly = (component as any).readonly || (props as any).readonly || componentConfig?.readonly || false;
const isDisabled = (component as any).disabled || (props as any).disabled || componentConfig?.disabled || false;
const isFieldDisabled = isDesignMode || isReadonly || isDisabled;
// 화면 컨텍스트 (데이터 제공자로 등록)
const screenContext = useScreenContextOptional();
@ -327,7 +331,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 클릭 이벤트 핸들러 (React Query로 간소화)
const handleToggle = () => {
if (isDesignMode) return;
if (isFieldDisabled) return; // 🆕 읽기전용/비활성화 상태에서는 토글 불가
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
setIsOpen(!isOpen);
@ -425,7 +429,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
value={option.value}
checked={selectedValue === option.value}
onChange={() => handleOptionSelect(option.value, option.label)}
disabled={isDesignMode}
disabled={isFieldDisabled}
className="border-input text-primary focus:ring-ring h-4 w-4"
/>
<span className="text-sm">{option.label}</span>
@ -456,12 +460,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
placeholder="코드 또는 코드명 입력..."
className={cn(
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
!isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
!isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
isSelected && "ring-2 ring-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
)}
readOnly={isDesignMode}
readOnly={isFieldDisabled}
disabled={isFieldDisabled}
/>
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
{isOpen && !isFieldDisabled && filteredOptions.length > 0 && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{filteredOptions.map((option, index) => (
<div
@ -490,13 +496,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<div className="w-full">
<div
className={cn(
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
"flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isOpen && "border-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
)}
onClick={handleToggle}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
<svg
@ -508,7 +515,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{isOpen && !isDesignMode && (
{isOpen && !isFieldDisabled && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{isLoadingCodes ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div>
@ -538,8 +545,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<div
className={cn(
"box-border flex h-full w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
!isFieldDisabled && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
)}
>
{selectedValues.map((val, idx) => {
@ -567,8 +575,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
type="text"
placeholder={selectedValues.length > 0 ? "" : placeholder}
className="min-w-[100px] flex-1 border-none bg-transparent outline-none"
onClick={() => setIsOpen(true)}
readOnly={isDesignMode}
onClick={() => !isFieldDisabled && setIsOpen(true)}
readOnly={isFieldDisabled}
disabled={isFieldDisabled}
/>
</div>
</div>
@ -589,19 +598,22 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
type="text"
value={searchQuery}
onChange={(e) => {
if (isFieldDisabled) return;
setSearchQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onFocus={() => !isFieldDisabled && setIsOpen(true)}
placeholder={placeholder}
className={cn(
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
!isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
!isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
isSelected && "ring-2 ring-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
)}
readOnly={isDesignMode}
readOnly={isFieldDisabled}
disabled={isFieldDisabled}
/>
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
{isOpen && !isFieldDisabled && filteredOptions.length > 0 && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{filteredOptions.map((option, index) => (
<div
@ -632,13 +644,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<div className="w-full">
<div
className={cn(
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
"flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isOpen && "border-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
)}
onClick={handleToggle}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
<svg
@ -650,7 +663,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{isOpen && !isDesignMode && (
{isOpen && !isFieldDisabled && (
<div className="absolute z-[99999] mt-1 w-full rounded-md border border-gray-300 bg-white shadow-lg">
<input
type="text"
@ -690,12 +703,13 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<div
className={cn(
"box-border flex w-full flex-wrap items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
!isFieldDisabled && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
)}
onClick={() => !isDesignMode && setIsOpen(true)}
onClick={() => !isFieldDisabled && setIsOpen(true)}
style={{
pointerEvents: isDesignMode ? "none" : "auto",
pointerEvents: isFieldDisabled ? "none" : "auto",
height: "100%"
}}
>
@ -726,7 +740,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<span className="text-gray-500">{placeholder}</span>
)}
</div>
{isOpen && !isDesignMode && (
{isOpen && !isFieldDisabled && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{(isLoadingCodes || isLoadingCategories) ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div>
@ -789,13 +803,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<div className="w-full">
<div
className={cn(
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
"flex h-10 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
!isFieldDisabled && "cursor-pointer hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isOpen && "border-orange-500",
isFieldDisabled && "bg-gray-100 cursor-not-allowed",
)}
onClick={handleToggle}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
style={{ pointerEvents: isFieldDisabled ? "none" : "auto" }}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
<svg
@ -807,7 +822,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{isOpen && !isDesignMode && (
{isOpen && !isFieldDisabled && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{isLoadingCodes ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div>

View File

@ -293,8 +293,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
) => {
if (value === null || value === undefined) return "-";
// 카테고리 매핑이 있는지 확인
const mapping = categoryMappings[columnName];
// 🆕 카테고리 매핑 찾기 (여러 키 형태 시도)
// 1. 전체 컬럼명 (예: "item_info.material")
// 2. 컬럼명만 (예: "material")
let mapping = categoryMappings[columnName];
if (!mapping && columnName.includes(".")) {
// 조인된 컬럼의 경우 컬럼명만으로 다시 시도
const simpleColumnName = columnName.split(".").pop() || columnName;
mapping = categoryMappings[simpleColumnName];
}
if (mapping && mapping[String(value)]) {
const categoryData = mapping[String(value)];
const displayLabel = categoryData.label || String(value);
@ -690,43 +699,69 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
loadLeftCategoryMappings();
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
// 우측 테이블 카테고리 매핑 로드
// 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함)
useEffect(() => {
const loadRightCategoryMappings = async () => {
const rightTableName = componentConfig.rightPanel?.tableName;
if (!rightTableName || isDesignMode) return;
try {
// 1. 컬럼 메타 정보 조회
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
if (categoryColumns.length === 0) {
setRightCategoryMappings({});
return;
}
// 2. 각 카테고리 컬럼에 대한 값 조회
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name;
try {
const response = await apiClient.get(`/table-categories/${rightTableName}/${columnName}/values`);
// 🆕 우측 패널 컬럼 설정에서 조인된 테이블 추출
const rightColumns = componentConfig.rightPanel?.columns || [];
const tablesToLoad = new Set<string>([rightTableName]);
// 컬럼명에서 테이블명 추출 (예: "item_info.material" -> "item_info")
rightColumns.forEach((col: any) => {
const colName = col.name || col.columnName;
if (colName && colName.includes(".")) {
const joinTableName = colName.split(".")[0];
tablesToLoad.add(joinTableName);
}
});
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
mappings[columnName] = valueMap;
console.log(`✅ 우측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
// 각 테이블에 대해 카테고리 매핑 로드
for (const tableName of tablesToLoad) {
try {
// 1. 컬럼 메타 정보 조회
const columnsResponse = await tableTypeApi.getColumns(tableName);
const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
// 2. 각 카테고리 컬럼에 대한 값 조회
for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name;
try {
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
// 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장
const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`;
mappings[mappingKey] = valueMap;
// 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블)
// 기존 매핑이 있으면 병합, 없으면 새로 생성
mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap };
console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap);
console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]);
}
} catch (error) {
console.error(`우측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error);
}
}
} catch (error) {
console.error(`우측 카테고리 값 조회 실패 [${columnName}]:`, error);
console.error(`테이블 ${tableName} 컬럼 정보 조회 실패:`, error);
}
}
@ -737,7 +772,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
};
loadRightCategoryMappings();
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]);
// 항목 펼치기/접기 토글
const toggleExpand = useCallback((itemId: any) => {
@ -2149,9 +2184,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const format = colConfig?.format;
const boldValue = colConfig?.bold ?? false;
// 숫자 포맷 적용
let displayValue = String(value || "-");
if (value !== null && value !== undefined && value !== "" && format) {
// 🆕 카테고리 매핑 적용
const formattedValue = formatCellValue(key, value, rightCategoryMappings);
// 숫자 포맷 적용 (카테고리가 아닌 경우만)
let displayValue: React.ReactNode = formattedValue;
if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) {
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
if (!isNaN(numValue)) {
displayValue = numValue.toLocaleString('ko-KR', {
@ -2175,7 +2213,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)}
<span
className={`text-foreground text-sm ${boldValue ? 'font-semibold' : ''}`}
title={displayValue}
>
{displayValue}
</span>
@ -2240,9 +2277,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const colConfig = rightColumns?.find(c => c.name === key);
const format = colConfig?.format;
// 숫자 포맷 적용
let displayValue = String(value);
if (value !== null && value !== undefined && value !== "" && format) {
// 🆕 카테고리 매핑 적용
const formattedValue = formatCellValue(key, value, rightCategoryMappings);
// 숫자 포맷 적용 (카테고리가 아닌 경우만)
let displayValue: React.ReactNode = formattedValue;
if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) {
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
if (!isNaN(numValue)) {
displayValue = numValue.toLocaleString('ko-KR', {

View File

@ -6,10 +6,30 @@ import {
SplitPanelLayout2Config,
ColumnConfig,
DataTransferField,
ActionButtonConfig,
} from "./types";
import { defaultConfig } from "./config";
import { cn } from "@/lib/utils";
import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2 } from "lucide-react";
import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2, Check, MoreHorizontal } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@ -59,6 +79,14 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({});
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({});
// 우측 패널 선택 상태 (체크박스용)
const [selectedRightItems, setSelectedRightItems] = useState<Set<string | number>>(new Set());
// 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<any>(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
// 좌측 데이터 로드
const loadLeftData = useCallback(async () => {
@ -233,6 +261,178 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
console.log("[SplitPanelLayout2] 우측 추가 모달 열기");
}, [config.rightPanel?.addModalScreenId, config.rightPanel?.addButtonLabel, config.dataTransferFields, selectedLeftItem, loadRightData]);
// 기본키 컬럼명 가져오기
const getPrimaryKeyColumn = useCallback(() => {
return config.rightPanel?.primaryKeyColumn || "id";
}, [config.rightPanel?.primaryKeyColumn]);
// 우측 패널 수정 버튼 클릭
const handleEditItem = useCallback((item: any) => {
// 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용)
const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
if (!modalScreenId) {
toast.error("연결된 모달 화면이 없습니다.");
return;
}
// EditModal 열기 이벤트 발생 (수정 모드)
const event = new CustomEvent("openEditModal", {
detail: {
screenId: modalScreenId,
title: "수정",
modalSize: "lg",
editData: item, // 기존 데이터 전달
isCreateMode: false, // 수정 모드
onSave: () => {
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
},
},
});
window.dispatchEvent(event);
console.log("[SplitPanelLayout2] 수정 모달 열기:", item);
}, [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData]);
// 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시)
const handleDeleteClick = useCallback((item: any) => {
setItemToDelete(item);
setIsBulkDelete(false);
setDeleteDialogOpen(true);
}, []);
// 일괄 삭제 버튼 클릭 (확인 다이얼로그 표시)
const handleBulkDeleteClick = useCallback(() => {
if (selectedRightItems.size === 0) {
toast.error("삭제할 항목을 선택해주세요.");
return;
}
setIsBulkDelete(true);
setDeleteDialogOpen(true);
}, [selectedRightItems.size]);
// 실제 삭제 실행
const executeDelete = useCallback(async () => {
if (!config.rightPanel?.tableName) {
toast.error("테이블 설정이 없습니다.");
return;
}
const pkColumn = getPrimaryKeyColumn();
try {
if (isBulkDelete) {
// 일괄 삭제
const idsToDelete = Array.from(selectedRightItems);
console.log("[SplitPanelLayout2] 일괄 삭제:", idsToDelete);
for (const id of idsToDelete) {
await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${id}`);
}
toast.success(`${idsToDelete.length}개 항목이 삭제되었습니다.`);
setSelectedRightItems(new Set());
} else if (itemToDelete) {
// 단일 삭제
const itemId = itemToDelete[pkColumn];
console.log("[SplitPanelLayout2] 단일 삭제:", itemId);
await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${itemId}`);
toast.success("항목이 삭제되었습니다.");
}
// 데이터 새로고침
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
} catch (error: any) {
console.error("[SplitPanelLayout2] 삭제 실패:", error);
toast.error(`삭제 실패: ${error.message}`);
} finally {
setDeleteDialogOpen(false);
setItemToDelete(null);
setIsBulkDelete(false);
}
}, [config.rightPanel?.tableName, getPrimaryKeyColumn, isBulkDelete, selectedRightItems, itemToDelete, selectedLeftItem, loadRightData]);
// 개별 체크박스 선택/해제
const handleSelectItem = useCallback((itemId: string | number, checked: boolean) => {
setSelectedRightItems((prev) => {
const newSet = new Set(prev);
if (checked) {
newSet.add(itemId);
} else {
newSet.delete(itemId);
}
return newSet;
});
}, []);
// 액션 버튼 클릭 핸들러
const handleActionButton = useCallback((btn: ActionButtonConfig) => {
switch (btn.action) {
case "add":
if (btn.modalScreenId) {
// 데이터 전달 필드 설정
const initialData: Record<string, any> = {};
if (selectedLeftItem && config.dataTransferFields) {
for (const field of config.dataTransferFields) {
if (field.sourceColumn && field.targetColumn) {
initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn];
}
}
}
const event = new CustomEvent("openEditModal", {
detail: {
screenId: btn.modalScreenId,
title: btn.label || "추가",
modalSize: "lg",
editData: initialData,
isCreateMode: true,
onSave: () => {
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
},
},
});
window.dispatchEvent(event);
}
break;
case "edit":
// 선택된 항목이 1개일 때만 수정
if (selectedRightItems.size === 1) {
const pkColumn = getPrimaryKeyColumn();
const selectedId = Array.from(selectedRightItems)[0];
const item = rightData.find((d) => d[pkColumn] === selectedId);
if (item) {
handleEditItem(item);
}
} else if (selectedRightItems.size > 1) {
toast.error("수정할 항목을 1개만 선택해주세요.");
} else {
toast.error("수정할 항목을 선택해주세요.");
}
break;
case "delete":
case "bulk-delete":
handleBulkDeleteClick();
break;
case "custom":
// 커스텀 액션 (추후 확장)
console.log("[SplitPanelLayout2] 커스텀 액션:", btn);
break;
default:
break;
}
}, [selectedLeftItem, config.dataTransferFields, loadRightData, selectedRightItems, getPrimaryKeyColumn, rightData, handleEditItem, handleBulkDeleteClick]);
// 컬럼 라벨 로드
const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record<string, string>) => void) => {
if (!tableName) return;
@ -366,6 +566,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
});
}, [rightData, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]);
// 체크박스 전체 선택/해제 (filteredRightData 정의 이후에 위치해야 함)
const handleSelectAll = useCallback((checked: boolean) => {
if (checked) {
const pkColumn = getPrimaryKeyColumn();
const allIds = new Set(filteredRightData.map((item) => item[pkColumn]));
setSelectedRightItems(allIds);
} else {
setSelectedRightItems(new Set());
}
}, [filteredRightData, getPrimaryKeyColumn]);
// 리사이즈 핸들러
const handleResizeStart = useCallback((e: React.MouseEvent) => {
if (!config.resizable) return;
@ -564,6 +775,10 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 우측 패널 카드 렌더링
const renderRightCard = (item: any, index: number) => {
const displayColumns = config.rightPanel?.displayColumns || [];
const showLabels = config.rightPanel?.showLabels ?? false;
const showCheckbox = config.rightPanel?.showCheckbox ?? false;
const pkColumn = getPrimaryKeyColumn();
const itemId = item[pkColumn];
// displayRow 설정에 따라 컬럼 분류
// displayRow가 "name"이면 이름 행, "info"이면 정보 행 (기본값: 첫 번째는 name, 나머지는 info)
@ -577,72 +792,113 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
return (
<Card key={index} className="mb-2 py-0 hover:shadow-md transition-shadow">
<CardContent className="px-4 py-2">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
{/* 체크박스 */}
{showCheckbox && (
<Checkbox
checked={selectedRightItems.has(itemId)}
onCheckedChange={(checked) => handleSelectItem(itemId, !!checked)}
className="mt-1"
/>
)}
<div className="flex-1">
{/* 이름 행 (Name Row) */}
{nameRowColumns.length > 0 && (
<div className="flex items-center gap-2 mb-2">
{nameRowColumns.map((col, idx) => {
const value = item[col.name];
if (!value && idx > 0) return null;
// 첫 번째 컬럼은 굵게 표시
if (idx === 0) {
return (
<span key={idx} className="font-semibold text-lg">
{formatValue(value, col.format) || "이름 없음"}
</span>
);
}
// 나머지는 배지 스타일
return (
<span key={idx} className="text-sm bg-muted px-2 py-0.5 rounded">
{formatValue(value, col.format)}
</span>
);
})}
{/* showLabels가 true이면 라벨: 값 형식으로 가로 배치 */}
{showLabels ? (
<div className="space-y-1">
{/* 이름 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
{nameRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
{nameRowColumns.map((col, idx) => {
const value = item[col.name];
if (value === null || value === undefined) return null;
return (
<span key={idx} className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">{col.label || col.name}:</span>
<span className="text-sm font-semibold">{formatValue(value, col.format)}</span>
</span>
);
})}
</div>
)}
{/* 정보 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
{infoRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
{infoRowColumns.map((col, idx) => {
const value = item[col.name];
if (value === null || value === undefined) return null;
return (
<span key={idx} className="flex items-center gap-1">
<span className="text-sm">{col.label || col.name}:</span>
<span className="text-sm">{formatValue(value, col.format)}</span>
</span>
);
})}
</div>
)}
</div>
)}
{/* 정보 행 (Info Row) */}
{infoRowColumns.length > 0 && (
<div className="flex flex-wrap gap-x-4 gap-y-1 text-base text-muted-foreground">
{infoRowColumns.map((col, idx) => {
const value = item[col.name];
if (!value) return null;
// 아이콘 결정
let icon = null;
const colName = col.name.toLowerCase();
if (colName.includes("tel") || colName.includes("phone")) {
icon = <span className="text-sm">tel</span>;
} else if (colName.includes("email")) {
icon = <span className="text-sm">@</span>;
} else if (colName.includes("sabun") || colName.includes("id")) {
icon = <span className="text-sm">ID</span>;
}
return (
<span key={idx} className="flex items-center gap-1">
{icon}
{formatValue(value, col.format)}
</span>
);
})}
) : (
// showLabels가 false일 때 기존 방식 유지 (라벨 없이 값만)
<div className="space-y-1">
{/* 이름 행 */}
{nameRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{nameRowColumns.map((col, idx) => {
const value = item[col.name];
if (value === null || value === undefined) return null;
if (idx === 0) {
return (
<span key={idx} className="font-semibold text-base">
{formatValue(value, col.format)}
</span>
);
}
return (
<span key={idx} className="text-sm bg-muted px-2 py-0.5 rounded">
{formatValue(value, col.format)}
</span>
);
})}
</div>
)}
{/* 정보 행 */}
{infoRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
{infoRowColumns.map((col, idx) => {
const value = item[col.name];
if (value === null || value === undefined) return null;
return (
<span key={idx} className="text-sm">
{formatValue(value, col.format)}
</span>
);
})}
</div>
)}
</div>
)}
</div>
{/* 액션 버튼 */}
{/* 액션 버튼 (개별 수정/삭제) */}
<div className="flex gap-1">
{config.rightPanel?.showEditButton && (
<Button variant="outline" size="sm" className="h-8">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEditItem(item)}
>
<Edit className="h-4 w-4" />
</Button>
)}
{config.rightPanel?.showDeleteButton && (
<Button variant="outline" size="sm" className="h-8">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleDeleteClick(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
@ -652,6 +908,139 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
);
};
// 우측 패널 테이블 렌더링
const renderRightTable = () => {
const displayColumns = config.rightPanel?.displayColumns || [];
const showCheckbox = config.rightPanel?.showCheckbox ?? true; // 테이블 모드는 기본 체크박스 표시
const pkColumn = getPrimaryKeyColumn();
const allSelected = filteredRightData.length > 0 &&
filteredRightData.every((item) => selectedRightItems.has(item[pkColumn]));
const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn]));
return (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
{showCheckbox && (
<TableHead className="w-12">
<Checkbox
checked={allSelected}
ref={(el) => {
if (el) {
(el as any).indeterminate = someSelected && !allSelected;
}
}}
onCheckedChange={handleSelectAll}
/>
</TableHead>
)}
{displayColumns.map((col, idx) => (
<TableHead
key={idx}
style={{ width: col.width ? `${col.width}px` : "auto" }}
>
{col.label || col.name}
</TableHead>
))}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
<TableHead className="w-24 text-center"></TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{filteredRightData.length === 0 ? (
<TableRow>
<TableCell
colSpan={displayColumns.length + (showCheckbox ? 1 : 0) + ((config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) ? 1 : 0)}
className="h-24 text-center text-muted-foreground"
>
</TableCell>
</TableRow>
) : (
filteredRightData.map((item, index) => {
const itemId = item[pkColumn];
return (
<TableRow key={index} className="hover:bg-muted/50">
{showCheckbox && (
<TableCell>
<Checkbox
checked={selectedRightItems.has(itemId)}
onCheckedChange={(checked) => handleSelectItem(itemId, !!checked)}
/>
</TableCell>
)}
{displayColumns.map((col, colIdx) => (
<TableCell key={colIdx}>
{formatValue(item[col.name], col.format)}
</TableCell>
))}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
<TableCell className="text-center">
<div className="flex justify-center gap-1">
{config.rightPanel?.showEditButton && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditItem(item)}
>
<Edit className="h-3.5 w-3.5" />
</Button>
)}
{config.rightPanel?.showDeleteButton && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteClick(item)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</TableCell>
)}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
);
};
// 액션 버튼 렌더링
const renderActionButtons = () => {
const actionButtons = config.rightPanel?.actionButtons;
if (!actionButtons || actionButtons.length === 0) return null;
return (
<div className="flex gap-2">
{actionButtons.map((btn) => (
<Button
key={btn.id}
variant={btn.variant || "default"}
size="sm"
className="h-8 text-sm"
onClick={() => handleActionButton(btn)}
disabled={
// 일괄 삭제 버튼은 선택된 항목이 없으면 비활성화
(btn.action === "bulk-delete" || btn.action === "delete") && selectedRightItems.size === 0
}
>
{btn.icon === "Plus" && <Plus className="h-4 w-4 mr-1" />}
{btn.icon === "Edit" && <Edit className="h-4 w-4 mr-1" />}
{btn.icon === "Trash2" && <Trash2 className="h-4 w-4 mr-1" />}
{btn.label}
</Button>
))}
</div>
);
};
// 디자인 모드 렌더링
if (isDesignMode) {
return (
@ -765,20 +1154,32 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
{/* 헤더 */}
<div className="p-4 border-b bg-muted/30">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-base">
{selectedLeftItem
? config.leftPanel?.displayColumns?.[0]
? selectedLeftItem[config.leftPanel.displayColumns[0].name]
: config.rightPanel?.title || "상세"
: config.rightPanel?.title || "상세"}
</h3>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-base">
{selectedLeftItem
? config.leftPanel?.displayColumns?.[0]
? selectedLeftItem[config.leftPanel.displayColumns[0].name]
: config.rightPanel?.title || "상세"
: config.rightPanel?.title || "상세"}
</h3>
{selectedLeftItem && (
<span className="text-sm text-muted-foreground">
{rightData.length}
({rightData.length})
</span>
)}
{config.rightPanel?.showAddButton && selectedLeftItem && (
{/* 선택된 항목 수 표시 */}
{selectedRightItems.size > 0 && (
<span className="text-sm text-primary font-medium">
{selectedRightItems.size}
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* 복수 액션 버튼 (actionButtons 설정 시) */}
{selectedLeftItem && renderActionButtons()}
{/* 기존 단일 추가 버튼 (하위 호환성) */}
{config.rightPanel?.showAddButton && selectedLeftItem && !config.rightPanel?.actionButtons?.length && (
<Button size="sm" variant="default" className="h-8 text-sm" onClick={handleRightAddClick}>
<Plus className="h-4 w-4 mr-1" />
{config.rightPanel?.addButtonLabel || "추가"}
@ -812,18 +1213,50 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
<div className="flex items-center justify-center h-full text-muted-foreground text-base">
...
</div>
) : filteredRightData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Users className="h-16 w-16 mb-3 opacity-30" />
<span className="text-base"> </span>
</div>
) : (
<div>
{filteredRightData.map((item, index) => renderRightCard(item, index))}
</div>
<>
{/* displayMode에 따라 카드 또는 테이블 렌더링 */}
{config.rightPanel?.displayMode === "table" ? (
renderRightTable()
) : filteredRightData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Users className="h-16 w-16 mb-3 opacity-30" />
<span className="text-base"> </span>
</div>
) : (
<div>
{filteredRightData.map((item, index) => renderRightCard(item, index))}
</div>
)}
</>
)}
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{isBulkDelete
? `선택한 ${selectedRightItems.size}개 항목을 삭제하시겠습니까?`
: "이 항목을 삭제하시겠습니까?"}
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={executeDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@ -530,6 +530,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
onValueChange={(value) => updateDisplayColumn("left", index, "name", value)}
placeholder="컬럼 선택"
/>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Input
value={col.label || ""}
onChange={(e) => updateDisplayColumn("left", index, "label", e.target.value)}
placeholder="라벨명 (미입력 시 컬럼명 사용)"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Select
@ -707,6 +716,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
placeholder="컬럼 선택"
/>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Input
value={col.label || ""}
onChange={(e) => updateDisplayColumn("right", index, "label", e.target.value)}
placeholder="라벨명 (미입력 시 컬럼명 사용)"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Select
@ -826,6 +844,254 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
</div>
</>
)}
{/* 표시 모드 설정 */}
<div className="pt-3 border-t">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.rightPanel?.displayMode || "card"}
onValueChange={(value) => updateConfig("rightPanel.displayMode", value)}
>
<SelectTrigger className="h-9 text-sm mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="card"></SelectItem>
<SelectItem value="table"></SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground mt-1">
카드형: 카드 , 테이블형:
</p>
</div>
{/* 카드 모드 전용 옵션 */}
{(config.rightPanel?.displayMode || "card") === "card" && (
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground">라벨: </p>
</div>
<Switch
checked={config.rightPanel?.showLabels || false}
onCheckedChange={(checked) => updateConfig("rightPanel.showLabels", checked)}
/>
</div>
)}
{/* 체크박스 표시 */}
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.rightPanel?.showCheckbox || false}
onCheckedChange={(checked) => updateConfig("rightPanel.showCheckbox", checked)}
/>
</div>
{/* 수정/삭제 버튼 */}
<div className="pt-3 border-t">
<Label className="text-xs font-medium"> /</Label>
<div className="mt-2 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.rightPanel?.showEditButton || false}
onCheckedChange={(checked) => updateConfig("rightPanel.showEditButton", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.rightPanel?.showDeleteButton || false}
onCheckedChange={(checked) => updateConfig("rightPanel.showDeleteButton", checked)}
/>
</div>
</div>
</div>
{/* 수정 모달 화면 (수정 버튼 활성화 시) */}
{config.rightPanel?.showEditButton && (
<div>
<Label className="text-xs"> </Label>
<ScreenSelect
value={config.rightPanel?.editModalScreenId}
onValueChange={(value) => updateConfig("rightPanel.editModalScreenId", value)}
placeholder="수정 모달 화면 선택 (미선택 시 추가 모달 사용)"
open={false}
onOpenChange={() => {}}
/>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
</div>
)}
{/* 기본키 컬럼 */}
<div>
<Label className="text-xs"> </Label>
<ColumnSelect
columns={rightColumns}
value={config.rightPanel?.primaryKeyColumn || ""}
onValueChange={(value) => updateConfig("rightPanel.primaryKeyColumn", value)}
placeholder="기본키 컬럼 선택 (기본: id)"
/>
<p className="text-[10px] text-muted-foreground mt-1">
/ ( id )
</p>
</div>
{/* 복수 액션 버튼 설정 */}
<div className="pt-3 border-t">
<div className="flex items-center justify-between mb-2">
<Label className="text-xs font-medium"> ()</Label>
<Button
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={() => {
const current = config.rightPanel?.actionButtons || [];
updateConfig("rightPanel.actionButtons", [
...current,
{
id: `btn-${Date.now()}`,
label: "새 버튼",
variant: "default",
action: "add",
},
]);
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground mb-2">
</p>
<div className="space-y-3">
{(config.rightPanel?.actionButtons || []).map((btn, index) => (
<div key={btn.id} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => {
const current = config.rightPanel?.actionButtons || [];
updateConfig(
"rightPanel.actionButtons",
current.filter((_, i) => i !== index)
);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Input
value={btn.label}
onChange={(e) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], label: e.target.value };
updateConfig("rightPanel.actionButtons", current);
}}
placeholder="버튼 라벨"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<Select
value={btn.action || "add"}
onValueChange={(value) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], action: value as any };
updateConfig("rightPanel.actionButtons", current);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="add"> ( )</SelectItem>
<SelectItem value="edit"> ( )</SelectItem>
<SelectItem value="bulk-delete"> ( )</SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<Select
value={btn.variant || "default"}
onValueChange={(value) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], variant: value as any };
updateConfig("rightPanel.actionButtons", current);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> (Primary)</SelectItem>
<SelectItem value="outline"></SelectItem>
<SelectItem value="destructive"> ()</SelectItem>
<SelectItem value="ghost"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<Select
value={btn.icon || "none"}
onValueChange={(value) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], icon: value === "none" ? undefined : value };
updateConfig("rightPanel.actionButtons", current);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="Plus">+ ()</SelectItem>
<SelectItem value="Edit"></SelectItem>
<SelectItem value="Trash2"></SelectItem>
</SelectContent>
</Select>
</div>
{btn.action === "add" && (
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<ScreenSelect
value={btn.modalScreenId}
onValueChange={(value) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], modalScreenId: value };
updateConfig("rightPanel.actionButtons", current);
}}
placeholder="모달 화면 선택"
open={false}
onOpenChange={() => {}}
/>
</div>
)}
</div>
))}
{(config.rightPanel?.actionButtons || []).length === 0 && (
<div className="text-center py-4 text-xs text-muted-foreground border rounded-md">
()
</div>
)}
</div>
</div>
</div>
</div>

View File

@ -22,6 +22,18 @@ export interface ColumnConfig {
};
}
/**
*
*/
export interface ActionButtonConfig {
id: string; // 고유 ID
label: string; // 버튼 라벨
variant?: "default" | "outline" | "destructive" | "ghost"; // 버튼 스타일
icon?: string; // lucide 아이콘명 (예: "Plus", "Edit", "Trash2")
modalScreenId?: number; // 연결할 모달 화면 ID
action?: "add" | "edit" | "delete" | "bulk-delete" | "custom"; // 버튼 동작 유형
}
/**
*
*/
@ -70,12 +82,17 @@ export interface RightPanelConfig {
searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성)
searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수)
showSearch?: boolean; // 검색 표시 여부
showAddButton?: boolean; // 추가 버튼 표시
addButtonLabel?: string; // 추가 버튼 라벨
addModalScreenId?: number; // 추가 모달 화면 ID
showEditButton?: boolean; // 수정 버튼 표시
showDeleteButton?: boolean; // 삭제 버튼 표시
displayMode?: "card" | "list"; // 표시 모드
showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성)
addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성)
addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성)
showEditButton?: boolean; // 수정 버튼 표시 (하위 호환성)
showDeleteButton?: boolean; // 삭제 버튼 표시 (하위 호환성)
editModalScreenId?: number; // 수정 모달 화면 ID
displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형)
showLabels?: boolean; // 카드 모드에서 라벨 표시 여부 (라벨: 값 형식)
showCheckbox?: boolean; // 체크박스 표시 여부 (테이블 모드에서 일괄 선택용)
actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열
primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id)
emptyMessage?: string; // 데이터 없을 때 메시지
}
@ -110,4 +127,3 @@ export interface SplitPanelLayout2Config {
// 동작 설정
autoLoad?: boolean; // 자동 데이터 로드
}

View File

@ -179,6 +179,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
config,
className,
style,
formData: propFormData, // 🆕 부모에서 전달받은 formData
onFormDataChange,
componentConfig,
onSelectedRowsChange,
@ -1183,13 +1184,74 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
referenceTable: col.additionalJoinInfo!.referenceTable,
}));
// console.log("🔍 [TableList] API 호출 시작", {
// tableName: tableConfig.selectedTable,
// page,
// pageSize,
// sortBy,
// sortOrder,
// });
// 🎯 화면별 엔티티 표시 설정 수집
const screenEntityConfigs: Record<string, any> = {};
(tableConfig.columns || [])
.filter((col) => col.entityDisplayConfig && col.entityDisplayConfig.displayColumns?.length > 0)
.forEach((col) => {
screenEntityConfigs[col.columnName] = {
displayColumns: col.entityDisplayConfig!.displayColumns,
separator: col.entityDisplayConfig!.separator || " - ",
sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable,
joinTable: col.entityDisplayConfig!.joinTable,
};
});
console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs);
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
let excludeFilterParam: any = undefined;
if (tableConfig.excludeFilter?.enabled) {
const excludeConfig = tableConfig.excludeFilter;
let filterValue: any = undefined;
// 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널)
if (excludeConfig.filterColumn && excludeConfig.filterValueField) {
const fieldName = excludeConfig.filterValueField;
// 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용)
if (propFormData && propFormData[fieldName]) {
filterValue = propFormData[fieldName];
console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", {
field: fieldName,
value: filterValue,
});
}
// 2순위: URL 파라미터에서 값 가져오기
else if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search);
filterValue = urlParams.get(fieldName);
if (filterValue) {
console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", {
field: fieldName,
value: filterValue,
});
}
}
// 3순위: 분할 패널 부모 데이터에서 값 가져오기
if (!filterValue && splitPanelContext?.selectedLeftData) {
filterValue = splitPanelContext.selectedLeftData[fieldName];
if (filterValue) {
console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", {
field: fieldName,
value: filterValue,
});
}
}
}
if (filterValue || !excludeConfig.filterColumn) {
excludeFilterParam = {
enabled: true,
referenceTable: excludeConfig.referenceTable,
referenceColumn: excludeConfig.referenceColumn,
sourceColumn: excludeConfig.sourceColumn,
filterColumn: excludeConfig.filterColumn,
filterValue: filterValue,
};
console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam);
}
}
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
@ -1200,7 +1262,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
search: hasFilters ? filters : undefined,
enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달
});
// 실제 데이터의 item_number만 추출하여 중복 확인
@ -1756,33 +1820,46 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const formatCellValue = useCallback(
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
if (value === null || value === undefined) return "-";
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
if (column.columnName === "writer" && rowData && rowData.writer_name) {
return rowData.writer_name;
}
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
// 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능
// 이 체크를 가장 먼저 수행 (null 체크보다 앞에)
if (column.entityDisplayConfig && rowData) {
// displayColumns 또는 selectedColumns 둘 다 체크
const displayColumns = column.entityDisplayConfig.displayColumns || column.entityDisplayConfig.selectedColumns;
const displayColumns = column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns;
const separator = column.entityDisplayConfig.separator;
if (displayColumns && displayColumns.length > 0) {
// 선택된 컬럼들의 값을 구분자로 조합
const values = displayColumns
.map((colName) => {
const cellValue = rowData[colName];
.map((colName: string) => {
// 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
let cellValue = rowData[colName];
// 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우)
if (cellValue === null || cellValue === undefined) {
const joinedKey = `${column.columnName}_${colName}`;
cellValue = rowData[joinedKey];
}
if (cellValue === null || cellValue === undefined) return "";
return String(cellValue);
})
.filter((v) => v !== ""); // 빈 값 제외
.filter((v: string) => v !== ""); // 빈 값 제외
return values.join(separator || " - ");
const result = values.join(separator || " - ");
if (result) {
return result; // 결과가 있으면 반환
}
// 결과가 비어있으면 아래로 계속 진행 (원래 값 사용)
}
}
// value가 null/undefined면 "-" 반환
if (value === null || value === undefined) return "-";
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
if (column.columnName === "writer" && rowData && rowData.writer_name) {
return rowData.writer_name;
}
const meta = columnMeta[column.columnName];
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
@ -1906,12 +1983,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return "-";
}
// 숫자 타입 포맷팅
// 숫자 타입 포맷팅 (천단위 구분자 설정 확인)
if (inputType === "number" || inputType === "decimal") {
if (value !== null && value !== undefined && value !== "") {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (!isNaN(numValue)) {
return numValue.toLocaleString("ko-KR");
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
if (column.thousandSeparator !== false) {
return numValue.toLocaleString("ko-KR");
}
return String(numValue);
}
}
return String(value);
@ -1922,7 +2003,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (value !== null && value !== undefined && value !== "") {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (!isNaN(numValue)) {
return numValue.toLocaleString("ko-KR");
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
if (column.thousandSeparator !== false) {
return numValue.toLocaleString("ko-KR");
}
return String(numValue);
}
}
return String(value);
@ -1939,10 +2024,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}
return "-";
case "number":
return typeof value === "number" ? value.toLocaleString() : value;
case "currency":
return typeof value === "number" ? `${value.toLocaleString()}` : value;
if (typeof value === "number") {
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
if (column.thousandSeparator !== false) {
return `${value.toLocaleString()}`;
}
return `${value}`;
}
return value;
case "boolean":
return value ? "예" : "아니오";
default:

View File

@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge";
import { TableListConfig, ColumnConfig } from "./types";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
@ -73,6 +74,12 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
// 🆕 제외 필터용 참조 테이블 컬럼 목록
const [referenceTableColumns, setReferenceTableColumns] = useState<
Array<{ columnName: string; dataType: string; label?: string }>
>([]);
const [loadingReferenceColumns, setLoadingReferenceColumns] = useState(false);
// 🔄 외부에서 config가 변경될 때 내부 상태 동기화 (표의 페이지네이션 변경 감지)
useEffect(() => {
// console.log("🔄 TableListConfigPanel - 외부 config 변경 감지:", {
@ -237,6 +244,42 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
fetchEntityJoinColumns();
}, [config.selectedTable, screenTableName]);
// 🆕 제외 필터용 참조 테이블 컬럼 가져오기
useEffect(() => {
const fetchReferenceColumns = async () => {
const refTable = config.excludeFilter?.referenceTable;
if (!refTable) {
setReferenceTableColumns([]);
return;
}
setLoadingReferenceColumns(true);
try {
console.log("🔗 참조 테이블 컬럼 정보 가져오기:", refTable);
const result = await tableManagementApi.getColumnList(refTable);
if (result.success && result.data) {
// result.data는 { columns: [], total, page, size, totalPages } 형태
const columns = result.data.columns || [];
setReferenceTableColumns(
columns.map((col: any) => ({
columnName: col.columnName || col.column_name,
dataType: col.dataType || col.data_type || "text",
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
}))
);
console.log("✅ 참조 테이블 컬럼 로드 완료:", columns.length, "개");
}
} catch (error) {
console.error("❌ 참조 테이블 컬럼 조회 오류:", error);
setReferenceTableColumns([]);
} finally {
setLoadingReferenceColumns(false);
}
};
fetchReferenceColumns();
}, [config.excludeFilter?.referenceTable]);
// 🎯 엔티티 컬럼 자동 로드
useEffect(() => {
const entityColumns = config.columns?.filter((col) => col.isEntityJoin && col.entityDisplayConfig);
@ -467,42 +510,22 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
// 🎯 엔티티 컬럼의 표시 컬럼 정보 로드
const loadEntityDisplayConfig = async (column: ColumnConfig) => {
if (!column.isEntityJoin || !column.entityJoinInfo) {
return;
}
const configKey = `${column.columnName}`;
// 이미 로드된 경우 스킵
if (entityDisplayConfigs[configKey]) return;
// entityDisplayConfig가 없으면 초기화
if (!column.entityDisplayConfig) {
// sourceTable을 결정: entityJoinInfo -> config.selectedTable -> screenTableName 순서
const initialSourceTable = column.entityJoinInfo?.sourceTable || config.selectedTable || screenTableName;
if (!initialSourceTable) {
return;
}
const updatedColumns = config.columns?.map((col) => {
if (col.columnName === column.columnName) {
return {
...col,
entityDisplayConfig: {
displayColumns: [],
separator: " - ",
sourceTable: initialSourceTable,
joinTable: "",
},
};
}
return col;
});
if (updatedColumns) {
handleChange("columns", updatedColumns);
// 업데이트된 컬럼으로 다시 시도
const updatedColumn = updatedColumns.find((col) => col.columnName === column.columnName);
if (updatedColumn) {
return loadEntityDisplayConfig(updatedColumn);
}
}
if (!column.isEntityJoin) {
// 엔티티 컬럼이 아니면 빈 상태로 설정하여 로딩 상태 해제
setEntityDisplayConfigs((prev) => ({
...prev,
[configKey]: {
sourceColumns: [],
joinColumns: [],
selectedColumns: [],
separator: " - ",
},
}));
return;
}
@ -512,32 +535,56 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
// 3. config.selectedTable
// 4. screenTableName
const sourceTable =
column.entityDisplayConfig.sourceTable ||
column.entityDisplayConfig?.sourceTable ||
column.entityJoinInfo?.sourceTable ||
config.selectedTable ||
screenTableName;
let joinTable = column.entityDisplayConfig.joinTable;
// sourceTable이 여전히 비어있으면 에러
// sourceTable이 비어있으면 빈 상태로 설정
if (!sourceTable) {
console.warn("⚠️ sourceTable을 찾을 수 없음:", column.columnName);
setEntityDisplayConfigs((prev) => ({
...prev,
[configKey]: {
sourceColumns: [],
joinColumns: [],
selectedColumns: column.entityDisplayConfig?.displayColumns || [],
separator: column.entityDisplayConfig?.separator || " - ",
},
}));
return;
}
if (!joinTable && sourceTable) {
// joinTable이 없으면 tableTypeApi로 조회해서 설정
let joinTable = column.entityDisplayConfig?.joinTable;
// joinTable이 없으면 tableTypeApi로 조회해서 설정
if (!joinTable) {
try {
console.log("🔍 tableTypeApi로 컬럼 정보 조회:", {
tableName: sourceTable,
columnName: column.columnName,
});
const columnList = await tableTypeApi.getColumns(sourceTable);
const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName);
console.log("🔍 컬럼 정보 조회 결과:", {
columnInfo: columnInfo,
referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable,
referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn,
});
if (columnInfo?.reference_table || columnInfo?.referenceTable) {
joinTable = columnInfo.reference_table || columnInfo.referenceTable;
console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", joinTable);
// entityDisplayConfig 업데이트
const updatedConfig = {
...column.entityDisplayConfig,
sourceTable: sourceTable,
joinTable: joinTable,
displayColumns: column.entityDisplayConfig?.displayColumns || [],
separator: column.entityDisplayConfig?.separator || " - ",
};
// 컬럼 설정 업데이트
@ -553,74 +600,27 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
}
} catch (error) {
console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
console.log("❌ 조회 실패 상세:", { sourceTable, columnName: column.columnName });
}
} else if (!joinTable) {
console.warn("⚠️ sourceTable이 없어서 joinTable 조회 불가:", column.columnName);
}
console.log("🔍 최종 추출한 값:", { sourceTable, joinTable });
const configKey = `${column.columnName}`;
// 이미 로드된 경우 스킵
if (entityDisplayConfigs[configKey]) return;
// joinTable이 비어있으면 tableTypeApi로 컬럼 정보를 다시 가져와서 referenceTable 정보를 찾기
let actualJoinTable = joinTable;
if (!actualJoinTable && sourceTable) {
try {
console.log("🔍 tableTypeApi로 컬럼 정보 다시 조회:", {
tableName: sourceTable,
columnName: column.columnName,
});
const columnList = await tableTypeApi.getColumns(sourceTable);
const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName);
console.log("🔍 컬럼 정보 조회 결과:", {
columnInfo: columnInfo,
referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable,
referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn,
});
if (columnInfo?.reference_table || columnInfo?.referenceTable) {
actualJoinTable = columnInfo.reference_table || columnInfo.referenceTable;
console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", actualJoinTable);
// entityDisplayConfig 업데이트
const updatedConfig = {
...column.entityDisplayConfig,
joinTable: actualJoinTable,
};
// 컬럼 설정 업데이트
const updatedColumns = config.columns?.map((col) =>
col.columnName === column.columnName ? { ...col, entityDisplayConfig: updatedConfig } : col,
);
if (updatedColumns) {
handleChange("columns", updatedColumns);
}
}
} catch (error) {
console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
}
}
// sourceTable과 joinTable이 모두 있어야 로드
if (!sourceTable || !actualJoinTable) {
return;
}
try {
// 기본 테이블과 조인 테이블의 컬럼 정보를 병렬로 로드
const [sourceResult, joinResult] = await Promise.all([
entityJoinApi.getReferenceTableColumns(sourceTable),
entityJoinApi.getReferenceTableColumns(actualJoinTable),
]);
// 기본 테이블 컬럼 정보는 항상 로드
const sourceResult = await entityJoinApi.getReferenceTableColumns(sourceTable);
const sourceColumns = sourceResult.columns || [];
const joinColumns = joinResult.columns || [];
// joinTable이 있으면 조인 테이블 컬럼도 로드
let joinColumns: Array<{ columnName: string; displayName: string; dataType: string }> = [];
if (joinTable) {
try {
const joinResult = await entityJoinApi.getReferenceTableColumns(joinTable);
joinColumns = joinResult.columns || [];
} catch (joinError) {
console.warn("⚠️ 조인 테이블 컬럼 로드 실패:", joinTable, joinError);
// 조인 테이블 로드 실패해도 소스 테이블 컬럼은 표시
}
}
setEntityDisplayConfigs((prev) => ({
...prev,
@ -633,6 +633,16 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
}));
} catch (error) {
console.error("엔티티 표시 컬럼 정보 로드 실패:", error);
// 에러 발생 시에도 빈 상태로 설정하여 로딩 상태 해제
setEntityDisplayConfigs((prev) => ({
...prev,
[configKey]: {
sourceColumns: [],
joinColumns: [],
selectedColumns: column.entityDisplayConfig?.displayColumns || [],
separator: column.entityDisplayConfig?.separator || " - ",
},
}));
}
};
@ -873,76 +883,95 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
{/* 표시 컬럼 선택 (다중 선택) */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-6 w-full justify-between text-xs"
style={{ fontSize: "12px" }}
>
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0
? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
: "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
<CommandGroup heading={`기본: ${column.entityDisplayConfig?.sourceTable}`}>
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
<CommandItem
key={`source-${col.columnName}`}
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
entityDisplayConfigs[column.columnName].selectedColumns.includes(
col.columnName,
)
? "opacity-100"
: "opacity-0",
)}
/>
{col.displayName}
</CommandItem>
))}
</CommandGroup>
)}
{entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
<CommandGroup heading={`조인: ${column.entityDisplayConfig?.joinTable}`}>
{entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
<CommandItem
key={`join-${col.columnName}`}
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
entityDisplayConfigs[column.columnName].selectedColumns.includes(
col.columnName,
)
? "opacity-100"
: "opacity-0",
)}
/>
{col.displayName}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{entityDisplayConfigs[column.columnName].sourceColumns.length === 0 &&
entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? (
<div className="py-2 text-center text-xs text-gray-400">
.
{!column.entityDisplayConfig?.joinTable && (
<p className="mt-1 text-[10px]">
.
</p>
)}
</div>
) : (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-6 w-full justify-between text-xs"
style={{ fontSize: "12px" }}
>
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0
? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
: "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
<CommandGroup heading={`기본 테이블: ${column.entityDisplayConfig?.sourceTable || config.selectedTable || screenTableName}`}>
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
<CommandItem
key={`source-${col.columnName}`}
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
entityDisplayConfigs[column.columnName].selectedColumns.includes(
col.columnName,
)
? "opacity-100"
: "opacity-0",
)}
/>
{col.displayName}
</CommandItem>
))}
</CommandGroup>
)}
{entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
<CommandGroup heading={`참조 테이블: ${column.entityDisplayConfig?.joinTable}`}>
{entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
<CommandItem
key={`join-${col.columnName}`}
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
entityDisplayConfigs[column.columnName].selectedColumns.includes(
col.columnName,
)
? "opacity-100"
: "opacity-0",
)}
/>
{col.displayName}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
{/* 참조 테이블 미설정 안내 */}
{!column.entityDisplayConfig?.joinTable && entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
<div className="rounded bg-blue-50 p-2 text-[10px] text-blue-600">
. .
</div>
)}
{/* 선택된 컬럼 미리보기 */}
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
<div className="space-y-1">
@ -1074,86 +1103,111 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
{/* 간결한 리스트 형식 컬럼 설정 */}
<div className="space-y-1">
{config.columns?.map((column, index) => (
<div
key={column.columnName}
className="hover:bg-muted/30 flex h-6 items-center justify-between rounded border px-2"
>
{/* 컬럼명 */}
<span className="flex-1 truncate text-xs" style={{ fontSize: "12px" }}>
{availableColumns.find((col) => col.columnName === column.columnName)?.label ||
column.displayName ||
column.columnName}
</span>
{config.columns?.map((column, index) => {
// 해당 컬럼의 input_type 확인
const columnInfo = availableColumns.find((col) => col.columnName === column.columnName);
const isNumberType = columnInfo?.input_type === "number" || columnInfo?.input_type === "decimal";
return (
<div
key={column.columnName}
className="hover:bg-muted/30 flex items-center justify-between rounded border px-2 py-1"
style={{ minHeight: "28px" }}
>
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden">
{/* 컬럼명 */}
<span className="truncate text-xs" style={{ fontSize: "12px" }}>
{columnInfo?.label || column.displayName || column.columnName}
</span>
{/* 숫자 타입인 경우 천단위 구분자 설정 */}
{isNumberType && (
<div className="flex items-center gap-1">
<Checkbox
id={`thousand-sep-${column.columnName}`}
checked={column.thousandSeparator !== false}
onCheckedChange={(checked) => {
updateColumn(column.columnName, { thousandSeparator: checked as boolean });
}}
className="h-3 w-3"
/>
<Label
htmlFor={`thousand-sep-${column.columnName}`}
className="text-[10px] text-muted-foreground cursor-pointer"
>
</Label>
</div>
)}
</div>
{/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */}
<div className="flex shrink-0 items-center gap-1">
<Checkbox
checked={config.filter?.filters?.some((f) => f.columnName === column.columnName) || false}
onCheckedChange={(checked) => {
const currentFilters = config.filter?.filters || [];
const columnLabel =
availableColumns.find((col) => col.columnName === column.columnName)?.label ||
column.displayName ||
column.columnName;
{/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */}
<div className="flex shrink-0 items-center gap-1">
<Checkbox
checked={config.filter?.filters?.some((f) => f.columnName === column.columnName) || false}
onCheckedChange={(checked) => {
const currentFilters = config.filter?.filters || [];
const columnLabel =
columnInfo?.label || column.displayName || column.columnName;
if (checked) {
// 필터 추가
handleChange("filter", {
...config.filter,
enabled: true,
filters: [
...currentFilters,
{
columnName: column.columnName,
label: columnLabel,
type: "text",
},
],
});
} else {
// 필터 제거
handleChange("filter", {
...config.filter,
filters: currentFilters.filter((f) => f.columnName !== column.columnName),
});
}
}}
className="h-3 w-3"
/>
if (checked) {
// 필터 추가
handleChange("filter", {
...config.filter,
enabled: true,
filters: [
...currentFilters,
{
columnName: column.columnName,
label: columnLabel,
type: "text",
},
],
});
} else {
// 필터 제거
handleChange("filter", {
...config.filter,
filters: currentFilters.filter((f) => f.columnName !== column.columnName),
});
}
}}
className="h-3 w-3"
/>
</div>
{/* 순서 변경 + 삭제 버튼 */}
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => moveColumn(column.columnName, "up")}
disabled={index === 0}
className="h-6 w-6 p-0"
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => moveColumn(column.columnName, "down")}
disabled={index === (config.columns?.length || 0) - 1}
className="h-6 w-6 p-0"
>
<ArrowDown className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeColumn(column.columnName)}
className="h-6 w-6 p-0 text-red-500 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 순서 변경 + 삭제 버튼 */}
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => moveColumn(column.columnName, "up")}
disabled={index === 0}
className="h-6 w-6 p-0"
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => moveColumn(column.columnName, "down")}
disabled={index === (config.columns?.length || 0) - 1}
className="h-6 w-6 p-0"
>
<ArrowDown className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeColumn(column.columnName)}
className="h-6 w-6 p-0 text-red-500 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
);
})}
</div>
</div>
)}
@ -1322,6 +1376,298 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</p>
</div>
</div>
{/* 🆕 제외 필터 설정 (다른 테이블에 이미 존재하는 데이터 제외) */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<hr className="border-border" />
{/* 제외 필터 활성화 */}
<div className="flex items-center gap-2">
<Checkbox
id="excludeFilter-enabled"
checked={config.excludeFilter?.enabled || false}
onCheckedChange={(checked) => {
handleChange("excludeFilter", {
...config.excludeFilter,
enabled: checked as boolean,
});
}}
/>
<Label htmlFor="excludeFilter-enabled" className="text-xs">
</Label>
</div>
{config.excludeFilter?.enabled && (
<div className="space-y-3 rounded border p-3">
{/* 참조 테이블 선택 */}
<div className="space-y-1">
<Label className="text-xs font-medium"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{config.excludeFilter?.referenceTable || "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => {
handleChange("excludeFilter", {
...config.excludeFilter,
referenceTable: table.tableName,
referenceColumn: undefined,
sourceColumn: undefined,
filterColumn: undefined,
filterValueField: undefined,
});
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.excludeFilter?.referenceTable === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{config.excludeFilter?.referenceTable && (
<>
{/* 비교 컬럼 설정 - 한 줄에 두 개 */}
<div className="grid grid-cols-2 gap-2">
{/* 참조 컬럼 (매핑 테이블) */}
<div className="space-y-1">
<Label className="text-xs"> ()</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={loadingReferenceColumns}
className="h-8 w-full justify-between text-xs"
>
{loadingReferenceColumns
? "..."
: config.excludeFilter?.referenceColumn || "선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"></CommandEmpty>
<CommandGroup>
{referenceTableColumns.map((col) => (
<CommandItem
key={col.columnName}
value={col.columnName}
onSelect={() => {
handleChange("excludeFilter", {
...config.excludeFilter,
referenceColumn: col.columnName,
});
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.excludeFilter?.referenceColumn === col.columnName ? "opacity-100" : "opacity-0"
)}
/>
{col.label || col.columnName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 소스 컬럼 (현재 테이블) */}
<div className="space-y-1">
<Label className="text-xs"> ()</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{config.excludeFilter?.sourceColumn || "선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"></CommandEmpty>
<CommandGroup>
{availableColumns.map((col) => (
<CommandItem
key={col.columnName}
value={col.columnName}
onSelect={() => {
handleChange("excludeFilter", {
...config.excludeFilter,
sourceColumn: col.columnName,
});
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.excludeFilter?.sourceColumn === col.columnName ? "opacity-100" : "opacity-0"
)}
/>
{col.label || col.columnName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
{/* 조건 필터 - 특정 조건의 데이터만 제외 */}
<div className="space-y-1">
<Label className="text-xs"> ()</Label>
<p className="text-[10px] text-muted-foreground mb-1">
(: 특정 )
</p>
<div className="grid grid-cols-2 gap-2">
{/* 필터 컬럼 (매핑 테이블) */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={loadingReferenceColumns}
className="h-8 w-full justify-between text-xs"
>
{loadingReferenceColumns
? "..."
: config.excludeFilter?.filterColumn
? `매핑: ${config.excludeFilter.filterColumn}`
: "매핑 테이블 컬럼"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"></CommandEmpty>
<CommandGroup>
<CommandItem
value=""
onSelect={() => {
handleChange("excludeFilter", {
...config.excludeFilter,
filterColumn: undefined,
filterValueField: undefined,
});
}}
className="text-xs text-muted-foreground"
>
<Check className={cn("mr-2 h-3 w-3", !config.excludeFilter?.filterColumn ? "opacity-100" : "opacity-0")} />
</CommandItem>
{referenceTableColumns.map((col) => (
<CommandItem
key={col.columnName}
value={col.columnName}
onSelect={() => {
// 필터 컬럼 선택 시 같은 이름의 필드를 자동으로 설정
handleChange("excludeFilter", {
...config.excludeFilter,
filterColumn: col.columnName,
filterValueField: col.columnName, // 같은 이름으로 자동 설정
filterValueSource: "url",
});
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.excludeFilter?.filterColumn === col.columnName ? "opacity-100" : "opacity-0"
)}
/>
{col.label || col.columnName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 필터 값 필드명 (부모 화면에서 전달받는 필드) */}
<Input
placeholder="예: customer_code"
value={config.excludeFilter?.filterValueField || ""}
onChange={(e) => {
handleChange("excludeFilter", {
...config.excludeFilter,
filterValueField: e.target.value,
});
}}
disabled={!config.excludeFilter?.filterColumn}
className="h-8 text-xs"
/>
</div>
</div>
</>
)}
{/* 설정 요약 */}
{config.excludeFilter?.referenceTable && config.excludeFilter?.referenceColumn && config.excludeFilter?.sourceColumn && (
<div className="rounded bg-muted/50 p-2 text-[10px] text-muted-foreground">
<strong> :</strong> {config.selectedTable || screenTableName}.{config.excludeFilter.sourceColumn}
{" "}{config.excludeFilter.referenceTable}.{config.excludeFilter.referenceColumn}
{config.excludeFilter.filterColumn && config.excludeFilter.filterValueField && (
<> ({config.excludeFilter.filterColumn}=URL의 {config.excludeFilter.filterValueField} )</>
)}
{" "}
</div>
)}
</div>
)}
</div>
</div>
</div>
);

View File

@ -59,6 +59,9 @@ export interface ColumnConfig {
isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부
entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보
// 숫자 포맷팅 설정
thousandSeparator?: boolean; // 천단위 구분자 사용 여부 (기본: true)
// 🎯 엔티티 컬럼 표시 설정 (화면별 동적 설정)
entityDisplayConfig?: {
displayColumns: string[]; // 표시할 컬럼들 (기본 테이블 + 조인 테이블)
@ -182,6 +185,21 @@ export interface LinkedFilterConfig {
enabled?: boolean; // 활성화 여부 (기본: true)
}
/**
*
*
* : 거래처에
*/
export interface ExcludeFilterConfig {
enabled: boolean; // 제외 필터 활성화 여부
referenceTable: string; // 참조 테이블 (예: customer_item_mapping)
referenceColumn: string; // 참조 테이블의 비교 컬럼 (예: item_id)
sourceColumn: string; // 현재 테이블의 비교 컬럼 (예: item_number)
filterColumn?: string; // 참조 테이블의 필터 컬럼 (예: customer_id)
filterValueSource?: "url" | "formData" | "parentData"; // 필터 값 소스 (기본: url)
filterValueField?: string; // 필터 값 필드명 (예: customer_code)
}
/**
* TableList
*/
@ -246,6 +264,9 @@ export interface TableListConfig extends ComponentConfig {
// 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링)
linkedFilters?: LinkedFilterConfig[];
// 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
excludeFilter?: ExcludeFilterConfig;
// 이벤트 핸들러
onRowClick?: (row: any) => void;
onRowDoubleClick?: (row: any) => void;

View File

@ -0,0 +1,35 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { UniversalFormModalDefinition } from "./index";
import { UniversalFormModalComponent } from "./UniversalFormModalComponent";
/**
*
*
*/
export class UniversalFormModalRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = UniversalFormModalDefinition;
render(): React.ReactElement {
return <UniversalFormModalComponent {...this.props} />;
}
/**
*
*/
protected handleFormDataChange = (data: any) => {
this.updateComponent({ formData: data });
};
/**
*
*/
protected handleSave = (data: any) => {
console.log("[UniversalFormModalRenderer] 저장 완료:", data);
};
}
// 자동 등록 실행
UniversalFormModalRenderer.registerSelf();

View File

@ -0,0 +1,138 @@
/**
*
*/
import { UniversalFormModalConfig } from "./types";
// 기본 설정값
export const defaultConfig: UniversalFormModalConfig = {
modal: {
title: "데이터 입력",
description: "",
size: "lg",
closeOnOutsideClick: false,
showCloseButton: true,
saveButtonText: "저장",
cancelButtonText: "취소",
showResetButton: false,
resetButtonText: "초기화",
},
sections: [
{
id: "default",
title: "기본 정보",
description: "",
collapsible: false,
defaultCollapsed: false,
columns: 2,
gap: "16px",
fields: [],
repeatable: false,
},
],
saveConfig: {
tableName: "",
primaryKeyColumn: "id",
multiRowSave: {
enabled: false,
commonFields: [],
repeatSectionId: "",
typeColumn: "",
mainTypeValue: "main",
subTypeValue: "concurrent",
mainSectionFields: [],
},
afterSave: {
closeModal: true,
refreshParent: true,
showToast: true,
},
},
editMode: {
enabled: false,
loadDataOnOpen: true,
identifierField: "id",
},
};
// 기본 필드 설정
export const defaultFieldConfig = {
id: "",
columnName: "",
label: "",
fieldType: "text" as const,
required: false,
defaultValue: "",
placeholder: "",
disabled: false,
readOnly: false,
width: "100%",
gridSpan: 6,
receiveFromParent: false,
};
// 기본 섹션 설정
export const defaultSectionConfig = {
id: "",
title: "새 섹션",
description: "",
collapsible: false,
defaultCollapsed: false,
columns: 2,
gap: "16px",
fields: [],
repeatable: false,
repeatConfig: {
minItems: 0,
maxItems: 10,
addButtonText: "+ 추가",
removeButtonText: "삭제",
itemTitle: "항목 {index}",
confirmRemove: false,
},
};
// 기본 채번규칙 설정
export const defaultNumberingRuleConfig = {
enabled: false,
ruleId: "",
editable: false,
hidden: false,
generateOnOpen: true,
generateOnSave: false,
};
// 기본 Select 옵션 설정
export const defaultSelectOptionsConfig = {
type: "static" as const,
staticOptions: [],
tableName: "",
valueColumn: "",
labelColumn: "",
filterCondition: "",
codeCategory: "",
};
// 모달 크기별 너비
export const MODAL_SIZE_MAP = {
sm: 400,
md: 600,
lg: 800,
xl: 1000,
full: "100%",
} as const;
// 유틸리티: 고유 ID 생성
export const generateUniqueId = (prefix: string = "item"): string => {
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
// 유틸리티: 섹션 ID 생성
export const generateSectionId = (): string => {
return generateUniqueId("section");
};
// 유틸리티: 필드 ID 생성
export const generateFieldId = (): string => {
return generateUniqueId("field");
};

View File

@ -0,0 +1,77 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { UniversalFormModalComponent } from "./UniversalFormModalComponent";
import { UniversalFormModalConfigPanel } from "./UniversalFormModalConfigPanel";
import { defaultConfig } from "./config";
/**
*
*
* , ,
* .
*/
export const UniversalFormModalDefinition = createComponentDefinition({
id: "universal-form-modal",
name: "범용 폼 모달",
nameEng: "Universal Form Modal",
description: "섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원하는 범용 모달 컴포넌트",
category: ComponentCategory.INPUT,
webType: "form",
component: UniversalFormModalComponent,
defaultConfig: defaultConfig,
defaultSize: {
width: 800,
height: 600,
gridColumnSpan: "12",
},
configPanel: UniversalFormModalConfigPanel,
icon: "FormInput",
tags: ["폼", "모달", "입력", "저장", "채번", "겸직", "다중행"],
version: "1.0.0",
author: "개발팀",
documentation: `
##
###
- ** **: ,
- ** **:
- ** **: ( )
- ** **: +
- ** **:
###
1. +
2. +
3. +
###
1.
2. ( , )
3.
4. ( )
5. ( )
6. ( )
`,
});
// 컴포넌트 내보내기
export { UniversalFormModalComponent } from "./UniversalFormModalComponent";
export { UniversalFormModalConfigPanel } from "./UniversalFormModalConfigPanel";
export { defaultConfig } from "./config";
// 타입 내보내기
export type {
UniversalFormModalConfig,
UniversalFormModalComponentProps,
UniversalFormModalConfigPanelProps,
FormSectionConfig,
FormFieldConfig,
SaveConfig,
MultiRowSaveConfig,
NumberingRuleConfig,
SelectOptionConfig,
FormDataState,
RepeatSectionItem,
} from "./types";

View File

@ -0,0 +1,259 @@
/**
*
*
* , ,
* .
*/
// Select 옵션 설정
export interface SelectOptionConfig {
type?: "static" | "table" | "code"; // 옵션 타입 (기본: static)
// 정적 옵션
staticOptions?: { value: string; label: string }[];
// 테이블 기반 옵션
tableName?: string;
valueColumn?: string;
labelColumn?: string;
filterCondition?: string;
// 공통코드 기반 옵션
codeCategory?: string;
}
// 채번규칙 설정
export interface NumberingRuleConfig {
enabled?: boolean; // 사용 여부 (기본: false)
ruleId?: string; // 채번규칙 ID
editable?: boolean; // 사용자 수정 가능 여부 (기본: false)
hidden?: boolean; // 숨김 여부 - 자동 저장만 (기본: false)
generateOnOpen?: boolean; // 모달 열릴 때 생성 (기본: true)
generateOnSave?: boolean; // 저장 시점에 생성 (기본: false)
}
// 필드 유효성 검사 설정
export interface FieldValidationConfig {
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
pattern?: string;
patternMessage?: string;
customValidator?: string; // 커스텀 검증 함수명
}
// 필드 설정
export interface FormFieldConfig {
id: string;
columnName: string; // DB 컬럼명
label: string; // 표시 라벨
fieldType:
| "text"
| "number"
| "date"
| "datetime"
| "select"
| "checkbox"
| "textarea"
| "password"
| "email"
| "tel";
required?: boolean;
defaultValue?: any;
placeholder?: string;
disabled?: boolean;
readOnly?: boolean;
hidden?: boolean; // 화면에 표시하지 않고 자동 저장만
// 레이아웃
width?: string; // 필드 너비 (예: "50%", "100%")
gridColumn?: number; // 그리드 컬럼 위치 (1-12)
gridSpan?: number; // 그리드 컬럼 스팬 (1-12)
// 채번규칙 설정
numberingRule?: NumberingRuleConfig;
// Select 옵션
selectOptions?: SelectOptionConfig;
// 유효성 검사
validation?: FieldValidationConfig;
// 외부 데이터 수신
receiveFromParent?: boolean; // 부모에서 값 받기
parentFieldName?: string; // 부모 필드명 (다르면 지정)
// 조건부 표시
visibleCondition?: {
field: string; // 참조할 필드
operator: "eq" | "ne" | "gt" | "lt" | "in" | "notIn";
value: any;
};
// 필드 간 연동
dependsOn?: {
field: string; // 의존하는 필드
action: "filter" | "setValue" | "clear";
config?: any;
};
}
// 반복 섹션 설정
export interface RepeatSectionConfig {
minItems?: number; // 최소 항목 수 (기본: 0)
maxItems?: number; // 최대 항목 수 (기본: 10)
addButtonText?: string; // 추가 버튼 텍스트 (기본: "+ 추가")
removeButtonText?: string; // 삭제 버튼 텍스트 (기본: "삭제")
itemTitle?: string; // 항목 제목 템플릿 (예: "겸직 {index}")
confirmRemove?: boolean; // 삭제 시 확인 (기본: false)
}
// 섹션 설정
export interface FormSectionConfig {
id: string;
title: string;
description?: string;
collapsible?: boolean; // 접을 수 있는지 (기본: false)
defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false)
fields: FormFieldConfig[];
// 반복 섹션 (겸직 등)
repeatable?: boolean;
repeatConfig?: RepeatSectionConfig;
// 섹션 레이아웃
columns?: number; // 필드 배치 컬럼 수 (기본: 2)
gap?: string; // 필드 간 간격
}
// 다중 행 저장 설정
export interface MultiRowSaveConfig {
enabled?: boolean; // 사용 여부 (기본: false)
commonFields?: string[]; // 모든 행에 공통 저장할 필드 (columnName 기준)
repeatSectionId?: string; // 반복 섹션 ID
typeColumn?: string; // 구분 컬럼명 (예: "employment_type")
mainTypeValue?: string; // 메인 행 값 (예: "main")
subTypeValue?: string; // 서브 행 값 (예: "concurrent")
// 메인 섹션 필드 (반복 섹션이 아닌 곳의 부서/직급 등)
mainSectionFields?: string[]; // 메인 행에만 저장할 필드
}
// 저장 설정
export interface SaveConfig {
tableName: string;
primaryKeyColumn?: string; // PK 컬럼 (수정 시 사용)
// 다중 행 저장 설정
multiRowSave?: MultiRowSaveConfig;
// 저장 후 동작 (간편 설정)
showToast?: boolean; // 토스트 메시지 (기본: true)
refreshParent?: boolean; // 부모 새로고침 (기본: true)
// 저장 후 동작 (상세 설정)
afterSave?: {
closeModal?: boolean; // 모달 닫기 (기본: true)
refreshParent?: boolean; // 부모 새로고침 (기본: true)
showToast?: boolean; // 토스트 메시지 (기본: true)
customAction?: string; // 커스텀 액션 이벤트명
};
}
// 모달 설정
export interface ModalConfig {
title: string;
description?: string;
size: "sm" | "md" | "lg" | "xl" | "full";
closeOnOutsideClick?: boolean;
showCloseButton?: boolean;
// 버튼 설정
saveButtonText?: string; // 저장 버튼 텍스트 (기본: "저장")
cancelButtonText?: string; // 취소 버튼 텍스트 (기본: "취소")
showResetButton?: boolean; // 초기화 버튼 표시
resetButtonText?: string; // 초기화 버튼 텍스트
}
// 전체 설정
export interface UniversalFormModalConfig {
modal: ModalConfig;
sections: FormSectionConfig[];
saveConfig: SaveConfig;
// 수정 모드 설정
editMode?: {
enabled: boolean;
loadDataOnOpen?: boolean; // 모달 열릴 때 데이터 로드
identifierField?: string; // 식별자 필드 (user_id 등)
};
}
// 반복 섹션 데이터 아이템
export interface RepeatSectionItem {
_id: string; // 내부 고유 ID
_index: number; // 인덱스
[key: string]: any; // 필드 데이터
}
// 폼 데이터 상태
export interface FormDataState {
// 일반 필드 데이터
[key: string]: any;
// 반복 섹션 데이터
_repeatSections?: {
[sectionId: string]: RepeatSectionItem[];
};
}
// 컴포넌트 Props
export interface UniversalFormModalComponentProps {
component?: any;
config?: UniversalFormModalConfig;
isDesignMode?: boolean;
isSelected?: boolean;
className?: string;
style?: React.CSSProperties;
// 외부에서 전달받는 초기 데이터
initialData?: Record<string, any>;
// 이벤트 핸들러
onSave?: (data: any) => void;
onCancel?: () => void;
onChange?: (data: FormDataState) => void;
}
// ConfigPanel Props
export interface UniversalFormModalConfigPanelProps {
config: UniversalFormModalConfig;
onChange: (config: UniversalFormModalConfig) => void;
}
// 필드 타입 옵션
export const FIELD_TYPE_OPTIONS = [
{ value: "text", label: "텍스트" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "datetime", label: "날짜시간" },
{ value: "select", label: "선택(드롭다운)" },
{ value: "checkbox", label: "체크박스" },
{ value: "textarea", label: "여러 줄 텍스트" },
{ value: "password", label: "비밀번호" },
{ value: "email", label: "이메일" },
{ value: "tel", label: "전화번호" },
] as const;
// 모달 크기 옵션
export const MODAL_SIZE_OPTIONS = [
{ value: "sm", label: "작게 (400px)" },
{ value: "md", label: "보통 (600px)" },
{ value: "lg", label: "크게 (800px)" },
{ value: "xl", label: "매우 크게 (1000px)" },
{ value: "full", label: "전체 화면" },
] as const;
// Select 옵션 타입
export const SELECT_OPTION_TYPE_OPTIONS = [
{ value: "static", label: "직접 입력" },
{ value: "table", label: "테이블 참조" },
{ value: "code", label: "공통코드" },
] as const;

View File

@ -1236,8 +1236,13 @@ export class ButtonActionExecutor {
} else {
console.log("🔄 테이블 데이터 삭제 완료, 테이블 새로고침 호출");
context.onRefresh?.(); // 테이블 새로고침
// 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생
window.dispatchEvent(new CustomEvent("refreshTable"));
console.log("🔄 refreshTable 전역 이벤트 발생");
}
toast.success(config.successMessage || `${dataToDelete.length}개 항목이 삭제되었습니다.`);
return true;
}
@ -1258,6 +1263,12 @@ export class ButtonActionExecutor {
}
context.onRefresh?.();
// 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생
window.dispatchEvent(new CustomEvent("refreshTable"));
console.log("🔄 refreshTable 전역 이벤트 발생 (단일 삭제)");
toast.success(config.successMessage || "삭제되었습니다.");
return true;
} catch (error) {
console.error("삭제 오류:", error);
@ -1536,6 +1547,13 @@ export class ButtonActionExecutor {
}
}
// 🆕 부모 화면의 선택된 데이터 가져오기 (excludeFilter에서 사용)
const parentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {};
console.log("📦 [openModalWithData] 부모 데이터 전달:", {
dataSourceId,
parentData,
});
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
@ -1544,6 +1562,7 @@ export class ButtonActionExecutor {
description: description,
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용)
},
});

View File

@ -60,7 +60,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.525.0",
"mammoth": "^1.11.0",
"next": "15.4.4",
"next": "^15.4.8",
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dnd": "^16.0.1",
@ -1145,9 +1145,9 @@
}
},
"node_modules/@next/env": {
"version": "15.4.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.4.tgz",
"integrity": "sha512-SJKOOkULKENyHSYXE5+KiFU6itcIb6wSBjgM92meK0HVKpo94dNOLZVdLLuS7/BxImROkGoPsjR4EnuDucqiiA==",
"version": "15.4.8",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.4.8.tgz",
"integrity": "sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@ -1161,9 +1161,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.4.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.4.tgz",
"integrity": "sha512-eVG55dnGwfUuG+TtnUCt+mEJ+8TGgul6nHEvdb8HEH7dmJIFYOCApAaFrIrxwtEq2Cdf+0m5sG1Np8cNpw9EAw==",
"version": "15.4.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.8.tgz",
"integrity": "sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A==",
"cpu": [
"arm64"
],
@ -1177,9 +1177,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.4.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.4.tgz",
"integrity": "sha512-zqG+/8apsu49CltEj4NAmCGZvHcZbOOOsNoTVeIXphYWIbE4l6A/vuQHyqll0flU2o3dmYCXsBW5FmbrGDgljQ==",
"version": "15.4.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.8.tgz",
"integrity": "sha512-xla6AOfz68a6kq3gRQccWEvFC/VRGJmA/QuSLENSO7CZX5WIEkSz7r1FdXUjtGCQ1c2M+ndUAH7opdfLK1PQbw==",
"cpu": [
"x64"
],
@ -1193,9 +1193,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.4.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.4.tgz",
"integrity": "sha512-LRD4l2lq4R+2QCHBQVC0wjxxkLlALGJCwigaJ5FSRSqnje+MRKHljQNZgDCaKUZQzO/TXxlmUdkZP/X3KNGZaw==",
"version": "15.4.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.8.tgz",
"integrity": "sha512-y3fmp+1Px/SJD+5ntve5QLZnGLycsxsVPkTzAc3zUiXYSOlTPqT8ynfmt6tt4fSo1tAhDPmryXpYKEAcoAPDJw==",
"cpu": [
"arm64"
],
@ -1209,9 +1209,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.4.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.4.tgz",
"integrity": "sha512-LsGUCTvuZ0690fFWerA4lnQvjkYg9gHo12A3wiPUR4kCxbx/d+SlwmonuTH2SWZI+RVGA9VL3N0S03WTYv6bYg==",
"version": "15.4.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.8.tgz",
"integrity": "sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==",
"cpu": [
"arm64"
],
@ -1225,9 +1225,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.4.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.4.tgz",
"integrity": "sha512-aOy5yNRpLL3wNiJVkFYl6w22hdREERNjvegE6vvtix8LHRdsTHhWTpgvcYdCK7AIDCQW5ATmzr9XkPHvSoAnvg==",
"version": "15.4.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.8.tgz",
"integrity": "sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==",
"cpu": [
"x64"
],
@ -1241,9 +1241,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.4.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.4.tgz",
"integrity": "sha512-FL7OAn4UkR8hKQRGBmlHiHinzOb07tsfARdGh7v0Z0jEJ3sz8/7L5bR23ble9E6DZMabSStqlATHlSxv1fuzAg==",
"version": "15.4.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.8.tgz",
"integrity": "sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==",
"cpu": [
"x64"
],
@ -1257,9 +1257,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.4.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.4.tgz",
"integrity": "sha512-eEdNW/TXwjYhOulQh0pffTMMItWVwKCQpbziSBmgBNFZIIRn2GTXrhrewevs8wP8KXWYMx8Z+mNU0X+AfvtrRg==",
"version": "15.4.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.8.tgz",
"integrity": "sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==",
"cpu": [
"arm64"
],
@ -1273,9 +1273,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.4.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.4.tgz",
"integrity": "sha512-SE5pYNbn/xZKMy1RE3pAs+4xD32OI4rY6mzJa4XUkp/ItZY+OMjIgilskmErt8ls/fVJ+Ihopi2QIeW6O3TrMw==",
"version": "15.4.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.8.tgz",
"integrity": "sha512-Exsmf/+42fWVnLMaZHzshukTBxZrSwuuLKFvqhGHJ+mC1AokqieLY/XzAl3jc/CqhXLqLY3RRjkKJ9YnLPcRWg==",
"cpu": [
"x64"
],
@ -10876,12 +10876,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "15.4.4",
"resolved": "https://registry.npmjs.org/next/-/next-15.4.4.tgz",
"integrity": "sha512-kNcubvJjOL9yUOfwtZF3HfDhuhp+kVD+FM2A6Tyua1eI/xfmY4r/8ZS913MMz+oWKDlbps/dQOWdDricuIkXLw==",
"version": "15.4.8",
"resolved": "https://registry.npmjs.org/next/-/next-15.4.8.tgz",
"integrity": "sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==",
"license": "MIT",
"dependencies": {
"@next/env": "15.4.4",
"@next/env": "15.4.8",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@ -10894,14 +10894,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.4.4",
"@next/swc-darwin-x64": "15.4.4",
"@next/swc-linux-arm64-gnu": "15.4.4",
"@next/swc-linux-arm64-musl": "15.4.4",
"@next/swc-linux-x64-gnu": "15.4.4",
"@next/swc-linux-x64-musl": "15.4.4",
"@next/swc-win32-arm64-msvc": "15.4.4",
"@next/swc-win32-x64-msvc": "15.4.4",
"@next/swc-darwin-arm64": "15.4.8",
"@next/swc-darwin-x64": "15.4.8",
"@next/swc-linux-arm64-gnu": "15.4.8",
"@next/swc-linux-arm64-musl": "15.4.8",
"@next/swc-linux-x64-gnu": "15.4.8",
"@next/swc-linux-x64-musl": "15.4.8",
"@next/swc-win32-arm64-msvc": "15.4.8",
"@next/swc-win32-x64-msvc": "15.4.8",
"sharp": "^0.34.3"
},
"peerDependencies": {

View File

@ -68,7 +68,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.525.0",
"mammoth": "^1.11.0",
"next": "15.4.4",
"next": "^15.4.8",
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dnd": "^16.0.1",

View File

@ -123,3 +123,24 @@ export const RESET_PERIOD_OPTIONS: Array<{
{ value: "monthly", label: "월별 초기화" },
{ value: "yearly", label: "연별 초기화" },
];
/**
*
* -
* - "none"
* - "custom" ( 2)
*/
export type SeparatorType = "none" | "-" | "_" | "." | "/" | "custom";
export const SEPARATOR_OPTIONS: Array<{
value: SeparatorType;
label: string;
displayValue: string;
}> = [
{ value: "none", label: "없음", displayValue: "" },
{ value: "-", label: "하이픈 (-)", displayValue: "-" },
{ value: "_", label: "언더스코어 (_)", displayValue: "_" },
{ value: ".", label: "점 (.)", displayValue: "." },
{ value: "/", label: "슬래시 (/)", displayValue: "/" },
{ value: "custom", label: "직접입력", displayValue: "" },
];

View File

@ -1679,3 +1679,4 @@ const 출고등록_설정: ScreenSplitPanel = {
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.

View File

@ -526,3 +526,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.

View File

@ -513,3 +513,4 @@ function ScreenViewPage() {
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.