Merge pull request 'feature/screen-management' (#177) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/177
This commit is contained in:
commit
4dba7c0a16
|
|
@ -2980,20 +2980,20 @@ export class TableManagementService {
|
|||
try {
|
||||
logger.info(`컬럼 입력타입 정보 조회: ${tableName}`);
|
||||
|
||||
// table_type_columns에서 입력타입 정보 조회
|
||||
// column_labels에서 입력타입 정보 조회
|
||||
const rawInputTypes = await query<any>(
|
||||
`SELECT
|
||||
ttc.column_name as "columnName",
|
||||
ttc.column_name as "displayName",
|
||||
COALESCE(ttc.input_type, 'text') as "inputType",
|
||||
COALESCE(ttc.detail_settings, '{}') as "detailSettings",
|
||||
ttc.is_nullable as "isNullable",
|
||||
cl.column_name as "columnName",
|
||||
cl.column_label as "displayName",
|
||||
COALESCE(cl.input_type, 'text') as "inputType",
|
||||
'{}'::jsonb as "detailSettings",
|
||||
ic.is_nullable as "isNullable",
|
||||
ic.data_type as "dataType"
|
||||
FROM table_type_columns ttc
|
||||
FROM column_labels cl
|
||||
LEFT JOIN information_schema.columns ic
|
||||
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
||||
WHERE ttc.table_name = $1
|
||||
ORDER BY ttc.display_order, ttc.column_name`,
|
||||
ON cl.table_name = ic.table_name AND cl.column_name = ic.column_name
|
||||
WHERE cl.table_name = $1
|
||||
ORDER BY cl.column_name`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,11 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
|
|||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 입력 필드에서 키 이벤트가 FlowEditor로 전파되지 않도록 방지
|
||||
// FlowEditor의 Backspace/Delete 키로 노드가 삭제되는 것을 막음
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="h-8 w-[200px] text-sm"
|
||||
placeholder="플로우 이름"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -102,13 +102,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
useEffect(() => {
|
||||
const handleOpenEditModal = (event: CustomEvent) => {
|
||||
const { screenId, title, description, modalSize, editData, onSave } = event.detail;
|
||||
console.log("🚀 EditModal 열기 이벤트 수신:", {
|
||||
screenId,
|
||||
title,
|
||||
description,
|
||||
modalSize,
|
||||
editData,
|
||||
});
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
|
|
@ -126,7 +119,16 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
};
|
||||
|
||||
const handleCloseEditModal = () => {
|
||||
console.log("🚪 EditModal 닫기 이벤트 수신");
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
if (modalState.onSave) {
|
||||
try {
|
||||
modalState.onSave();
|
||||
} catch (callbackError) {
|
||||
console.error("⚠️ onSave 콜백 에러:", callbackError);
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
handleClose();
|
||||
};
|
||||
|
||||
|
|
@ -137,7 +139,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
|
||||
window.removeEventListener("closeEditModal", handleCloseEditModal);
|
||||
};
|
||||
}, []);
|
||||
}, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조
|
||||
|
||||
// 화면 데이터 로딩
|
||||
useEffect(() => {
|
||||
|
|
@ -211,12 +213,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
try {
|
||||
console.log("💾 수정 저장 시작:", {
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
formData,
|
||||
originalData,
|
||||
});
|
||||
|
||||
// 변경된 필드만 추출
|
||||
const changedData: Record<string, any> = {};
|
||||
Object.keys(formData).forEach((key) => {
|
||||
|
|
@ -225,26 +221,33 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
});
|
||||
|
||||
console.log("📝 변경된 필드:", changedData);
|
||||
|
||||
if (Object.keys(changedData).length === 0) {
|
||||
toast.info("변경된 내용이 없습니다.");
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// 기본키 확인 (id 또는 첫 번째 키)
|
||||
const recordId = originalData.id || Object.values(originalData)[0];
|
||||
|
||||
// UPDATE 액션 실행
|
||||
const response = await dynamicFormApi.updateData(screenData.screenInfo.tableName, {
|
||||
...originalData, // 원본 데이터 (WHERE 조건용)
|
||||
...changedData, // 변경된 데이터만
|
||||
});
|
||||
const response = await dynamicFormApi.updateFormDataPartial(
|
||||
recordId,
|
||||
originalData,
|
||||
changedData,
|
||||
screenData.screenInfo.tableName,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success("데이터가 수정되었습니다.");
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
if (modalState.onSave) {
|
||||
modalState.onSave();
|
||||
try {
|
||||
modalState.onSave();
|
||||
} catch (callbackError) {
|
||||
console.error("⚠️ onSave 콜백 에러:", callbackError);
|
||||
}
|
||||
}
|
||||
|
||||
handleClose();
|
||||
|
|
@ -335,16 +338,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log(`🎯 EditModal onFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||
console.log("📋 현재 formData:", formData);
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📝 EditModal 업데이트된 formData:", newFormData);
|
||||
return newFormData;
|
||||
});
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
|
|
|
|||
|
|
@ -38,10 +38,12 @@ import {
|
|||
Folder,
|
||||
FolderOpen,
|
||||
Grid,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file";
|
||||
|
|
@ -99,6 +101,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
onRefresh,
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||
|
|
@ -106,6 +109,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const hasInitializedWidthsRef = useRef(false);
|
||||
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
||||
const isResizingRef = useRef(false);
|
||||
|
||||
// SaveModal 상태 (등록/수정 통합)
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
|
|
@ -130,6 +137,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
// 공통코드 관리 상태
|
||||
const [codeOptions, setCodeOptions] = useState<Record<string, Array<{ value: string; label: string }>>>({});
|
||||
|
||||
// 🆕 검색 필터 관련 상태 (FlowWidget과 동일)
|
||||
const [searchFilterColumns, setSearchFilterColumns] = useState<Set<string>>(new Set()); // 검색 필터로 사용할 컬럼
|
||||
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); // 필터 설정 다이얼로그
|
||||
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
|
||||
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
|
||||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
|
||||
|
||||
// 공통코드 옵션 가져오기
|
||||
const loadCodeOptions = useCallback(
|
||||
async (categoryCode: string) => {
|
||||
|
|
@ -408,6 +422,35 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
// 페이지 크기 설정
|
||||
const pageSize = component.pagination?.pageSize || 10;
|
||||
|
||||
// 초기 컬럼 너비 측정 (한 번만)
|
||||
useEffect(() => {
|
||||
if (!hasInitializedWidthsRef.current && visibleColumns.length > 0) {
|
||||
// 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정
|
||||
const timer = setTimeout(() => {
|
||||
const newWidths: Record<string, number> = {};
|
||||
let hasAnyWidth = false;
|
||||
|
||||
visibleColumns.forEach((column) => {
|
||||
const thElement = columnRefs.current[column.id];
|
||||
if (thElement) {
|
||||
const measuredWidth = thElement.offsetWidth;
|
||||
if (measuredWidth > 0) {
|
||||
newWidths[column.id] = measuredWidth;
|
||||
hasAnyWidth = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasAnyWidth) {
|
||||
setColumnWidths(newWidths);
|
||||
hasInitializedWidthsRef.current = true;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
// 데이터 로드 함수
|
||||
const loadData = useCallback(
|
||||
async (page: number = 1, searchParams: Record<string, any> = {}) => {
|
||||
|
|
@ -600,6 +643,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
try {
|
||||
const columns = await tableTypeApi.getColumns(component.tableName);
|
||||
setTableColumns(columns);
|
||||
|
||||
// 🆕 전체 컬럼 목록 설정
|
||||
const columnNames = columns.map(col => col.columnName);
|
||||
setAllAvailableColumns(columnNames);
|
||||
|
||||
// 🆕 컬럼명 -> 라벨 매핑 생성
|
||||
const labels: Record<string, string> = {};
|
||||
columns.forEach(col => {
|
||||
labels[col.columnName] = col.displayName || col.columnName;
|
||||
});
|
||||
setColumnLabels(labels);
|
||||
|
||||
// 🆕 localStorage에서 필터 설정 복원
|
||||
if (user?.userId && component.componentId) {
|
||||
const storageKey = `table-search-filter-${user.userId}-${component.componentId}`;
|
||||
const savedFilter = localStorage.getItem(storageKey);
|
||||
if (savedFilter) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedFilter);
|
||||
setSearchFilterColumns(new Set(parsed));
|
||||
} catch (e) {
|
||||
console.error("필터 설정 복원 실패:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("테이블 컬럼 정보 로드 실패:", error);
|
||||
}
|
||||
|
|
@ -608,7 +676,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
if (component.tableName) {
|
||||
fetchTableColumns();
|
||||
}
|
||||
}, [component.tableName]);
|
||||
}, [component.tableName, component.componentId, user?.userId]);
|
||||
|
||||
// 실제 사용할 필터 (설정된 필터만 사용, 자동 생성 안함)
|
||||
const searchFilters = useMemo(() => {
|
||||
|
|
@ -769,7 +837,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
setShowSaveModal(true);
|
||||
}, [getDisplayColumns, generateAutoValue, component.addModalConfig]);
|
||||
|
||||
// 데이터 수정 핸들러 (SaveModal 사용)
|
||||
// 데이터 수정 핸들러 (EditModal 사용)
|
||||
const handleEditData = useCallback(() => {
|
||||
if (selectedRows.size !== 1) return;
|
||||
|
||||
|
|
@ -793,17 +861,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
initialData[col.columnName] = selectedRowData[col.columnName] || "";
|
||||
});
|
||||
|
||||
setEditFormData(initialData);
|
||||
setEditingRowData(selectedRowData);
|
||||
|
||||
// 수정 모달 설정에서 제목과 설명 가져오기
|
||||
const editModalTitle = component.editModalConfig?.title || "";
|
||||
const editModalTitle = component.editModalConfig?.title || "데이터 수정";
|
||||
const editModalDescription = component.editModalConfig?.description || "";
|
||||
|
||||
console.log("📝 수정 모달 설정:", { editModalTitle, editModalDescription });
|
||||
|
||||
setShowEditModal(true);
|
||||
}, [selectedRows, data, getDisplayColumns, component.editModalConfig]);
|
||||
// 전역 EditModal 열기 이벤트 발생
|
||||
const event = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
screenId,
|
||||
title: editModalTitle,
|
||||
description: editModalDescription,
|
||||
modalSize: "lg",
|
||||
editData: initialData,
|
||||
onSave: () => {
|
||||
loadData(); // 테이블 데이터 새로고침
|
||||
},
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}, [selectedRows, data, getDisplayColumns, component.addModalConfig, component.editModalConfig, loadData]);
|
||||
|
||||
// 수정 폼 데이터 변경 핸들러
|
||||
const handleEditFormChange = useCallback((columnName: string, value: any) => {
|
||||
|
|
@ -1011,6 +1087,29 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
}
|
||||
}, [isAdding]);
|
||||
|
||||
// 🆕 검색 필터 저장 함수
|
||||
const handleSaveSearchFilter = useCallback(() => {
|
||||
if (user?.userId && component.componentId) {
|
||||
const storageKey = `table-search-filter-${user.userId}-${component.componentId}`;
|
||||
const filterArray = Array.from(searchFilterColumns);
|
||||
localStorage.setItem(storageKey, JSON.stringify(filterArray));
|
||||
toast.success("검색 필터 설정이 저장되었습니다.");
|
||||
}
|
||||
}, [user?.userId, component.componentId, searchFilterColumns]);
|
||||
|
||||
// 🆕 검색 필터 토글 함수
|
||||
const handleToggleFilterColumn = useCallback((columnName: string) => {
|
||||
setSearchFilterColumns((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(columnName)) {
|
||||
newSet.delete(columnName);
|
||||
} else {
|
||||
newSet.add(columnName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 데이터 삭제 핸들러
|
||||
const handleDeleteData = useCallback(() => {
|
||||
if (selectedRows.size === 0) {
|
||||
|
|
@ -1767,8 +1866,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
case "number":
|
||||
case "decimal":
|
||||
if (typeof value === "number") {
|
||||
return value.toLocaleString();
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (!isNaN(numValue)) {
|
||||
return numValue.toLocaleString("ko-KR");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -1909,27 +2011,97 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
{visibleColumns.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<Table style={{ tableLayout: 'fixed' }}>
|
||||
<TableHeader className="bg-gradient-to-b from-muted/50 to-muted border-b-2 border-primary/20">
|
||||
<TableRow>
|
||||
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
||||
{component.enableDelete && (
|
||||
<TableHead className="w-12 px-4">
|
||||
<TableHead
|
||||
className="px-4"
|
||||
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRows.size === data.length && data.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableHead
|
||||
key={column.id}
|
||||
className="bg-gradient-to-r from-gray-50 to-slate-50 px-4 font-semibold text-gray-700"
|
||||
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
|
||||
>
|
||||
{column.label}
|
||||
</TableHead>
|
||||
))}
|
||||
{visibleColumns.map((column: DataTableColumn, columnIndex) => {
|
||||
const columnWidth = columnWidths[column.id];
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={column.id}
|
||||
ref={(el) => (columnRefs.current[column.id] = el)}
|
||||
className="relative px-4 font-bold text-foreground/90 select-none text-center hover:bg-muted/70 transition-colors"
|
||||
style={{
|
||||
width: columnWidth ? `${columnWidth}px` : undefined,
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
{/* 리사이즈 핸들 */}
|
||||
{columnIndex < visibleColumns.length - 1 && (
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
|
||||
style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const thElement = columnRefs.current[column.id];
|
||||
if (!thElement) return;
|
||||
|
||||
isResizingRef.current = true;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startWidth = columnWidth || thElement.offsetWidth;
|
||||
|
||||
// 드래그 중 텍스트 선택 방지
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
moveEvent.preventDefault();
|
||||
|
||||
const diff = moveEvent.clientX - startX;
|
||||
const newWidth = Math.max(80, startWidth + diff);
|
||||
|
||||
// 직접 DOM 스타일 변경 (리렌더링 없음)
|
||||
if (thElement) {
|
||||
thElement.style.width = `${newWidth}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// 최종 너비를 state에 저장
|
||||
if (thElement) {
|
||||
const finalWidth = Math.max(80, thElement.offsetWidth);
|
||||
setColumnWidths(prev => ({ ...prev, [column.id]: finalWidth }));
|
||||
}
|
||||
|
||||
// 텍스트 선택 복원
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
|
||||
// 약간의 지연 후 리사이즈 플래그 해제
|
||||
setTimeout(() => {
|
||||
isResizingRef.current = false;
|
||||
}, 100);
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
{/* 자동 파일 컬럼 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -1951,18 +2123,28 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100">
|
||||
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
||||
{component.enableDelete && (
|
||||
<TableCell className="w-12 px-4">
|
||||
<TableCell
|
||||
className="px-4"
|
||||
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRows.has(rowIndex)}
|
||||
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableCell key={column.id} className="px-4 text-sm font-medium text-gray-900">
|
||||
{formatCellValue(row[column.columnName], column, row)}
|
||||
</TableCell>
|
||||
))}
|
||||
{visibleColumns.map((column: DataTableColumn) => {
|
||||
const isNumeric = column.widgetType === "number" || column.widgetType === "decimal";
|
||||
return (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className="px-4 text-sm font-medium text-gray-900 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
style={{ textAlign: isNumeric ? 'right' : 'left' }}
|
||||
>
|
||||
{formatCellValue(row[column.columnName], column, row)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
{/* 자동 파일 셀 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */}
|
||||
</TableRow>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ interface InteractiveScreenViewerProps {
|
|||
id: number;
|
||||
tableName?: string;
|
||||
};
|
||||
onSave?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||
|
|
@ -47,6 +48,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
onFormDataChange,
|
||||
hideLabel = false,
|
||||
screenInfo,
|
||||
onSave,
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { userName, user } = useAuth();
|
||||
|
|
@ -204,8 +206,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
// 테이블 컴포넌트는 자체적으로 loadData 호출
|
||||
}}
|
||||
onClose={() => {
|
||||
// 화면 닫기 로직 (필요시 구현)
|
||||
console.log("🚪 화면 닫기 요청");
|
||||
// buttonActions.ts가 이미 처리함
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
@ -299,6 +300,18 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
// 버튼 액션 핸들러들
|
||||
const handleSaveAction = async () => {
|
||||
// EditModal에서 전달된 onSave가 있으면 우선 사용 (수정 모달)
|
||||
if (onSave) {
|
||||
try {
|
||||
await onSave();
|
||||
} catch (error) {
|
||||
console.error("저장 오류:", error);
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 일반 저장 액션 (신규 생성)
|
||||
if (!screenInfo?.tableName) {
|
||||
toast.error("테이블명이 설정되지 않았습니다.");
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
const [assignmentSuccess, setAssignmentSuccess] = useState(false);
|
||||
const [assignmentMessage, setAssignmentMessage] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const autoRedirectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 메뉴 목록 로드 (관리자 메뉴 + 사용자 메뉴)
|
||||
const loadMenus = async () => {
|
||||
|
|
@ -98,7 +99,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 모달이 열릴 때 메뉴 목록 로드
|
||||
// 모달이 열릴 때 메뉴 목록 로드 및 정리
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadMenus();
|
||||
|
|
@ -107,7 +108,21 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
setSearchTerm("");
|
||||
setAssignmentSuccess(false);
|
||||
setAssignmentMessage("");
|
||||
} else {
|
||||
// 모달이 닫힐 때 타이머 정리
|
||||
if (autoRedirectTimerRef.current) {
|
||||
clearTimeout(autoRedirectTimerRef.current);
|
||||
autoRedirectTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 컴포넌트 언마운트 시 타이머 정리
|
||||
return () => {
|
||||
if (autoRedirectTimerRef.current) {
|
||||
clearTimeout(autoRedirectTimerRef.current);
|
||||
autoRedirectTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 메뉴 선택 처리
|
||||
|
|
@ -208,7 +223,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
}
|
||||
|
||||
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
|
||||
setTimeout(() => {
|
||||
autoRedirectTimerRef.current = setTimeout(() => {
|
||||
onClose(); // 모달 닫기
|
||||
if (onBackToList) {
|
||||
onBackToList();
|
||||
|
|
@ -237,7 +252,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
}
|
||||
|
||||
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
|
||||
setTimeout(() => {
|
||||
autoRedirectTimerRef.current = setTimeout(() => {
|
||||
onClose(); // 모달 닫기
|
||||
if (onBackToList) {
|
||||
onBackToList();
|
||||
|
|
@ -374,13 +389,20 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// 타이머 정리
|
||||
if (autoRedirectTimerRef.current) {
|
||||
clearTimeout(autoRedirectTimerRef.current);
|
||||
autoRedirectTimerRef.current = null;
|
||||
}
|
||||
|
||||
// 화면 목록으로 이동
|
||||
if (onBackToList) {
|
||||
onBackToList();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
className="bg-green-600 text-white hover:bg-green-700"
|
||||
>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
화면 목록으로 이동
|
||||
|
|
@ -536,7 +558,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
<Button
|
||||
onClick={handleAssignScreen}
|
||||
disabled={!selectedMenu || assigning}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
{assigning ? (
|
||||
<>
|
||||
|
|
@ -616,7 +638,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
await performAssignment(true);
|
||||
}}
|
||||
disabled={assigning}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
className="bg-orange-600 text-white hover:bg-orange-700"
|
||||
>
|
||||
{assigning ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -275,9 +275,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
style={{ ...baseStyle, ...selectionStyle }}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
draggable={isDesignMode} // 디자인 모드에서만 드래그 가능
|
||||
onDragStart={isDesignMode ? handleDragStart : undefined}
|
||||
onDragEnd={isDesignMode ? handleDragEnd : undefined}
|
||||
>
|
||||
{/* 동적 컴포넌트 렌더링 */}
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -381,19 +381,37 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
// 실행취소
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
setHistoryIndex((prev) => prev - 1);
|
||||
setLayout(history[historyIndex - 1]);
|
||||
}
|
||||
}, [history, historyIndex]);
|
||||
setHistoryIndex((prevIndex) => {
|
||||
if (prevIndex > 0) {
|
||||
const newIndex = prevIndex - 1;
|
||||
setHistory((prevHistory) => {
|
||||
if (prevHistory[newIndex]) {
|
||||
setLayout(prevHistory[newIndex]);
|
||||
}
|
||||
return prevHistory;
|
||||
});
|
||||
return newIndex;
|
||||
}
|
||||
return prevIndex;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 다시실행
|
||||
const redo = useCallback(() => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
setHistoryIndex((prev) => prev + 1);
|
||||
setLayout(history[historyIndex + 1]);
|
||||
}
|
||||
}, [history, historyIndex]);
|
||||
setHistoryIndex((prevIndex) => {
|
||||
let newIndex = prevIndex;
|
||||
setHistory((prevHistory) => {
|
||||
if (prevIndex < prevHistory.length - 1) {
|
||||
newIndex = prevIndex + 1;
|
||||
if (prevHistory[newIndex]) {
|
||||
setLayout(prevHistory[newIndex]);
|
||||
}
|
||||
}
|
||||
return prevHistory;
|
||||
});
|
||||
return newIndex;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 컴포넌트 속성 업데이트
|
||||
const updateComponentProperty = useCallback(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Table, Filter, Search, Download, RefreshCw, Plus, Edit, Trash2 } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -8,6 +8,9 @@ import { Input } from "@/components/ui/input";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
/**
|
||||
* 데이터 테이블 템플릿 컴포넌트
|
||||
|
|
@ -121,6 +124,13 @@ export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
|
|||
className = "",
|
||||
isPreview = true,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
|
||||
// 🆕 검색 필터 관련 상태
|
||||
const [searchFilterColumns, setSearchFilterColumns] = useState<Set<string>>(new Set());
|
||||
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
|
||||
const [searchValues, setSearchValues] = useState<Record<string, string>>({});
|
||||
|
||||
// 설정된 컬럼만 사용 (자동 생성 안함)
|
||||
const defaultColumns = React.useMemo(() => {
|
||||
return columns || [];
|
||||
|
|
@ -138,6 +148,54 @@ export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
|
|||
}, [isPreview]);
|
||||
|
||||
const visibleColumns = defaultColumns.filter((col) => col.visible);
|
||||
|
||||
// 🆕 컬럼명 -> 라벨 매핑
|
||||
const columnLabels = React.useMemo(() => {
|
||||
const labels: Record<string, string> = {};
|
||||
defaultColumns.forEach(col => {
|
||||
labels[col.id] = col.label;
|
||||
});
|
||||
return labels;
|
||||
}, [defaultColumns]);
|
||||
|
||||
// 🆕 localStorage에서 필터 설정 복원
|
||||
useEffect(() => {
|
||||
if (user?.userId && title) {
|
||||
const storageKey = `datatable-search-filter-${user.userId}-${title}`;
|
||||
const savedFilter = localStorage.getItem(storageKey);
|
||||
if (savedFilter) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedFilter);
|
||||
setSearchFilterColumns(new Set(parsed));
|
||||
} catch (e) {
|
||||
console.error("필터 설정 복원 실패:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [user?.userId, title]);
|
||||
|
||||
// 🆕 필터 저장 함수
|
||||
const handleSaveSearchFilter = useCallback(() => {
|
||||
if (user?.userId && title) {
|
||||
const storageKey = `datatable-search-filter-${user.userId}-${title}`;
|
||||
const filterArray = Array.from(searchFilterColumns);
|
||||
localStorage.setItem(storageKey, JSON.stringify(filterArray));
|
||||
toast.success("검색 필터 설정이 저장되었습니다.");
|
||||
}
|
||||
}, [user?.userId, title, searchFilterColumns]);
|
||||
|
||||
// 🆕 필터 토글 함수
|
||||
const handleToggleFilterColumn = useCallback((columnId: string) => {
|
||||
setSearchFilterColumns((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(columnId)) {
|
||||
newSet.delete(columnId);
|
||||
} else {
|
||||
newSet.add(columnId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className={`h-full w-full ${className}`} style={style}>
|
||||
|
|
@ -178,23 +236,65 @@ export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
|
|||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* 🆕 검색 필터 설정 버튼 영역 */}
|
||||
{defaultColumns.length > 0 && (
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsFilterSettingOpen(true)}
|
||||
disabled={isPreview}
|
||||
className="gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
검색 필터 설정
|
||||
{searchFilterColumns.size > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-[10px]">
|
||||
{searchFilterColumns.size}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 선택된 컬럼의 검색 입력 필드 */}
|
||||
{searchFilterColumns.size > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 rounded-lg border bg-gray-50/50 p-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from(searchFilterColumns).map((columnId) => {
|
||||
const column = defaultColumns.find(col => col.id === columnId);
|
||||
if (!column) return null;
|
||||
|
||||
return (
|
||||
<div key={columnId} className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-gray-700">
|
||||
{column.label}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={`${column.label} 검색...`}
|
||||
value={searchValues[columnId] || ""}
|
||||
onChange={(e) => setSearchValues(prev => ({...prev, [columnId]: e.target.value}))}
|
||||
disabled={isPreview}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 및 필터 영역 */}
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||
{/* 검색 입력 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative min-w-[200px] flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input placeholder="검색어를 입력하세요..." className="pl-10" disabled={isPreview} />
|
||||
</div>
|
||||
|
||||
{actions.showSearchButton && (
|
||||
<Button variant="outline" disabled={isPreview}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
{actions.searchButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 영역 */}
|
||||
{/* 기존 필터 영역 (이제는 사용하지 않음) */}
|
||||
{filters.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="text-muted-foreground h-4 w-4" />
|
||||
|
|
@ -352,6 +452,46 @@ export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
|
|||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* 🆕 검색 필터 설정 다이얼로그 */}
|
||||
<Dialog open={isFilterSettingOpen} onOpenChange={setIsFilterSettingOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>검색 필터 설정</DialogTitle>
|
||||
<DialogDescription>
|
||||
표시할 검색 필터를 선택하세요. 선택하지 않은 필터는 숨겨집니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] space-y-2 overflow-y-auto py-4">
|
||||
{defaultColumns.map((column) => (
|
||||
<div key={column.id} className="flex items-center space-x-3 rounded-lg p-3 hover:bg-gray-50">
|
||||
<Checkbox
|
||||
id={`filter-${column.id}`}
|
||||
checked={searchFilterColumns.has(column.id)}
|
||||
onCheckedChange={() => handleToggleFilterColumn(column.id)}
|
||||
/>
|
||||
<label htmlFor={`filter-${column.id}`} className="flex-1 cursor-pointer text-sm">
|
||||
{column.label}
|
||||
<span className="ml-2 text-xs text-gray-500">({column.type})</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setIsFilterSettingOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
handleSaveSearchFilter();
|
||||
setIsFilterSettingOpen(false);
|
||||
}}>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|||
import { FlowComponent } from "@/types/screen-management";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle, Loader2, ChevronUp, Filter, X } from "lucide-react";
|
||||
import { AlertCircle, Loader2, ChevronUp, Filter, X, Layers, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
getFlowById,
|
||||
getAllStepCounts,
|
||||
|
|
@ -40,6 +40,14 @@ import { Input } from "@/components/ui/input";
|
|||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
// 그룹화된 데이터 인터페이스
|
||||
interface GroupedData {
|
||||
groupKey: string;
|
||||
groupValues: Record<string, any>;
|
||||
items: any[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface FlowWidgetProps {
|
||||
component: FlowComponent;
|
||||
onStepClick?: (stepId: number, stepName: string) => void;
|
||||
|
|
@ -58,6 +66,28 @@ export function FlowWidget({
|
|||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||
|
||||
// 숫자 포맷팅 함수
|
||||
const formatValue = (value: any): string => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
|
||||
// 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅
|
||||
if (typeof value === "number") {
|
||||
return value.toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const numValue = parseFloat(value);
|
||||
// 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅
|
||||
if (!isNaN(numValue) && numValue.toString() === value.trim()) {
|
||||
return numValue.toLocaleString("ko-KR");
|
||||
}
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// 🆕 전역 상태 관리
|
||||
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
|
||||
const resetFlow = useFlowStepStore((state) => state.resetFlow);
|
||||
|
|
@ -84,6 +114,11 @@ export function FlowWidget({
|
|||
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
|
||||
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
|
||||
|
||||
// 🆕 그룹 설정 관련 상태
|
||||
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그
|
||||
const [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set()); // 접힌 그룹
|
||||
|
||||
/**
|
||||
* 🆕 컬럼 표시 결정 함수
|
||||
* 1순위: 플로우 스텝 기본 설정 (displayConfig)
|
||||
|
|
@ -125,6 +160,12 @@ export function FlowWidget({
|
|||
return `flowWidget_searchFilters_${user.userId}_${flowId}_${selectedStepId}`;
|
||||
}, [flowId, selectedStepId, user?.userId]);
|
||||
|
||||
// 🆕 그룹 설정 localStorage 키 생성
|
||||
const groupSettingKey = useMemo(() => {
|
||||
if (!selectedStepId) return null;
|
||||
return `flowWidget_groupSettings_step_${selectedStepId}`;
|
||||
}, [selectedStepId]);
|
||||
|
||||
// 🆕 저장된 필터 설정 불러오기
|
||||
useEffect(() => {
|
||||
if (!filterSettingKey || stepDataColumns.length === 0 || !user?.userId) return;
|
||||
|
|
@ -141,43 +182,30 @@ export function FlowWidget({
|
|||
// 초기값: 빈 필터 (사용자가 선택해야 함)
|
||||
setSearchFilterColumns(new Set());
|
||||
}
|
||||
|
||||
// 이전 사용자의 필터 설정 정리 (사용자 ID가 다른 키들 제거)
|
||||
if (typeof window !== "undefined") {
|
||||
const currentUserId = user.userId;
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
// localStorage의 모든 키를 확인
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith("flowWidget_searchFilters_")) {
|
||||
// 키 형식: flowWidget_searchFilters_${userId}_${flowId}_${stepId}
|
||||
// split("_")를 하면 ["flowWidget", "searchFilters", "사용자ID", "플로우ID", "스텝ID"]
|
||||
// 따라서 userId는 parts[2]입니다
|
||||
const parts = key.split("_");
|
||||
if (parts.length >= 3) {
|
||||
const userIdFromKey = parts[2]; // flowWidget_searchFilters_ 다음이 userId
|
||||
// 현재 사용자 ID와 다른 사용자의 설정은 제거
|
||||
if (userIdFromKey !== currentUserId) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 이전 사용자의 설정 제거
|
||||
if (keysToRemove.length > 0) {
|
||||
keysToRemove.forEach(key => {
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("필터 설정 불러오기 실패:", error);
|
||||
setSearchFilterColumns(new Set());
|
||||
}
|
||||
}, [filterSettingKey, stepDataColumns, user?.userId]);
|
||||
|
||||
// 🆕 저장된 그룹 설정 불러오기
|
||||
useEffect(() => {
|
||||
if (!groupSettingKey || stepDataColumns.length === 0) return;
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(groupSettingKey);
|
||||
if (saved) {
|
||||
const savedGroups = JSON.parse(saved);
|
||||
// 현재 단계에 표시되는 컬럼만 필터링
|
||||
const validGroups = savedGroups.filter((col: string) => stepDataColumns.includes(col));
|
||||
setGroupByColumns(validGroups);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹 설정 불러오기 실패:", error);
|
||||
setGroupByColumns([]);
|
||||
}
|
||||
}, [groupSettingKey, stepDataColumns]);
|
||||
|
||||
// 🆕 필터 설정 저장
|
||||
const saveFilterSettings = useCallback(() => {
|
||||
if (!filterSettingKey) return;
|
||||
|
|
@ -225,6 +253,92 @@ export function FlowWidget({
|
|||
setFilteredData([]);
|
||||
}, []);
|
||||
|
||||
// 🆕 그룹 설정 저장
|
||||
const saveGroupSettings = useCallback(() => {
|
||||
if (!groupSettingKey) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
|
||||
setIsGroupSettingOpen(false);
|
||||
toast.success("그룹 설정이 저장되었습니다");
|
||||
} catch (error) {
|
||||
console.error("그룹 설정 저장 실패:", error);
|
||||
toast.error("설정 저장에 실패했습니다");
|
||||
}
|
||||
}, [groupSettingKey, groupByColumns]);
|
||||
|
||||
// 🆕 그룹 컬럼 토글
|
||||
const toggleGroupColumn = useCallback((columnName: string) => {
|
||||
setGroupByColumns((prev) => {
|
||||
if (prev.includes(columnName)) {
|
||||
return prev.filter((col) => col !== columnName);
|
||||
} else {
|
||||
return [...prev, columnName];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 🆕 그룹 펼치기/접기 토글
|
||||
const toggleGroupCollapse = useCallback((groupKey: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(groupKey)) {
|
||||
newSet.delete(groupKey);
|
||||
} else {
|
||||
newSet.add(groupKey);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 🆕 그룹 해제
|
||||
const clearGrouping = useCallback(() => {
|
||||
setGroupByColumns([]);
|
||||
setCollapsedGroups(new Set());
|
||||
if (groupSettingKey) {
|
||||
localStorage.removeItem(groupSettingKey);
|
||||
}
|
||||
toast.success("그룹이 해제되었습니다");
|
||||
}, [groupSettingKey]);
|
||||
|
||||
// 🆕 데이터 그룹화
|
||||
const groupedData = useMemo((): GroupedData[] => {
|
||||
const dataToGroup = filteredData.length > 0 ? filteredData : stepData;
|
||||
|
||||
if (groupByColumns.length === 0 || dataToGroup.length === 0) return [];
|
||||
|
||||
const grouped = new Map<string, any[]>();
|
||||
|
||||
dataToGroup.forEach((item) => {
|
||||
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
||||
const keyParts = groupByColumns.map((col) => {
|
||||
const value = item[col];
|
||||
const label = columnLabels[col] || col;
|
||||
return `${label}:${value !== null && value !== undefined ? value : "-"}`;
|
||||
});
|
||||
const groupKey = keyParts.join(" > ");
|
||||
|
||||
if (!grouped.has(groupKey)) {
|
||||
grouped.set(groupKey, []);
|
||||
}
|
||||
grouped.get(groupKey)!.push(item);
|
||||
});
|
||||
|
||||
return Array.from(grouped.entries()).map(([groupKey, items]) => {
|
||||
const groupValues: Record<string, any> = {};
|
||||
groupByColumns.forEach((col) => {
|
||||
groupValues[col] = items[0]?.[col];
|
||||
});
|
||||
|
||||
return {
|
||||
groupKey,
|
||||
groupValues,
|
||||
items,
|
||||
count: items.length,
|
||||
};
|
||||
});
|
||||
}, [filteredData, stepData, groupByColumns, columnLabels]);
|
||||
|
||||
// 🆕 검색 값이 변경될 때마다 자동 검색 (useEffect로 직접 처리)
|
||||
useEffect(() => {
|
||||
if (!stepData || stepData.length === 0) {
|
||||
|
|
@ -657,17 +771,6 @@ export function FlowWidget({
|
|||
|
||||
return (
|
||||
<div className="@container flex w-full flex-col p-2 sm:p-4 lg:p-6">
|
||||
{/* 플로우 제목 */}
|
||||
<div className="mb-3 flex-shrink-0 sm:mb-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<h3 className="text-foreground text-base font-semibold sm:text-lg lg:text-xl">{flowData.name}</h3>
|
||||
</div>
|
||||
|
||||
{flowData.description && (
|
||||
<p className="text-muted-foreground mt-1 text-center text-xs sm:text-sm">{flowData.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 플로우 스텝 목록 */}
|
||||
<div className={`${containerClass} flex-shrink-0`}>
|
||||
{steps.map((step, index) => (
|
||||
|
|
@ -698,7 +801,7 @@ export function FlowWidget({
|
|||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium sm:text-base">
|
||||
{stepCounts[step.id] || 0}
|
||||
{(stepCounts[step.id] || 0).toLocaleString("ko-KR")}
|
||||
</span>
|
||||
<span className="text-xs font-normal sm:text-sm">건</span>
|
||||
</div>
|
||||
|
|
@ -754,85 +857,115 @@ export function FlowWidget({
|
|||
{/* 선택된 스텝의 데이터 리스트 */}
|
||||
{selectedStepId !== null && (
|
||||
<div className="mt-4 flex w-full flex-col sm:mt-6 lg:mt-8">
|
||||
{/* 헤더 - 자동 높이 */}
|
||||
<div className="bg-background flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-foreground text-base font-semibold sm:text-lg">
|
||||
{steps.find((s) => s.id === selectedStepId)?.stepName}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
|
||||
총 {stepData.length}건의 데이터
|
||||
{filteredData.length > 0 && (
|
||||
<span className="text-primary ml-2 font-medium">(필터링: {filteredData.length}건)</span>
|
||||
{/* 필터 및 그룹 설정 */}
|
||||
{stepDataColumns.length > 0 && (
|
||||
<>
|
||||
<div className="bg-background flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 검색 필터 입력 영역 */}
|
||||
{searchFilterColumns.size > 0 && (
|
||||
<>
|
||||
{Array.from(searchFilterColumns).map((col) => (
|
||||
<Input
|
||||
key={col}
|
||||
value={searchValues[col] || ""}
|
||||
onChange={(e) =>
|
||||
setSearchValues((prev) => ({
|
||||
...prev,
|
||||
[col]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={`${columnLabels[col] || col} 검색...`}
|
||||
className="h-8 text-xs w-40"
|
||||
/>
|
||||
))}
|
||||
{Object.keys(searchValues).length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearSearch} className="h-8 text-xs">
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
초기화
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="text-primary ml-2 font-medium">({selectedRows.size}건 선택됨)</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* 필터/그룹 설정 버튼 */}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
setIsFilterSettingOpen(true);
|
||||
}}
|
||||
disabled={isPreviewMode}
|
||||
className="h-8 shrink-0 text-xs sm:text-sm"
|
||||
>
|
||||
<Filter className="mr-2 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
검색 필터 설정
|
||||
{searchFilterColumns.size > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]">
|
||||
{searchFilterColumns.size}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
setIsGroupSettingOpen(true);
|
||||
}}
|
||||
disabled={isPreviewMode}
|
||||
className="h-8 shrink-0 text-xs sm:text-sm"
|
||||
>
|
||||
<Layers className="mr-2 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
그룹 설정
|
||||
{groupByColumns.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]">
|
||||
{groupByColumns.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 필터 설정 버튼 */}
|
||||
{stepDataColumns.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
setIsFilterSettingOpen(true);
|
||||
}}
|
||||
disabled={isPreviewMode}
|
||||
className="h-8 shrink-0 text-xs sm:text-sm"
|
||||
>
|
||||
<Filter className="mr-2 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
검색 필터 설정
|
||||
{searchFilterColumns.size > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]">
|
||||
{searchFilterColumns.size}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 🆕 검색 필터 입력 영역 */}
|
||||
{searchFilterColumns.size > 0 && (
|
||||
<div className="mt-2 space-y-3 p-4">
|
||||
<div className="flex items-center justify-end">
|
||||
{Object.keys(searchValues).length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearSearch} className="h-7 text-xs">
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||
{Array.from(searchFilterColumns).map((col) => (
|
||||
<div key={col} className="space-y-1.5">
|
||||
<Label htmlFor={`search-${col}`} className="text-xs">
|
||||
{columnLabels[col] || col}
|
||||
</Label>
|
||||
<Input
|
||||
id={`search-${col}`}
|
||||
value={searchValues[col] || ""}
|
||||
onChange={(e) =>
|
||||
setSearchValues((prev) => ({
|
||||
...prev,
|
||||
[col]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={`${columnLabels[col] || col} 검색...`}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* 🆕 그룹 표시 배지 */}
|
||||
{groupByColumns.length > 0 && (
|
||||
<div className="border-b border-border bg-muted/30 px-4 py-2">
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<span className="text-muted-foreground">그룹:</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{groupByColumns.map((col, idx) => (
|
||||
<span key={col} className="flex items-center">
|
||||
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
||||
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
||||
{columnLabels[col] || col}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isPreviewMode) {
|
||||
clearGrouping();
|
||||
}
|
||||
}}
|
||||
disabled={isPreviewMode}
|
||||
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
||||
title="그룹 해제"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 데이터 영역 - 고정 높이 + 스크롤 */}
|
||||
{stepDataLoading ? (
|
||||
|
|
@ -884,13 +1017,7 @@ export function FlowWidget({
|
|||
{stepDataColumns.map((col) => (
|
||||
<div key={col} className="flex justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span>
|
||||
<span className="text-foreground truncate">
|
||||
{row[col] !== null && row[col] !== undefined ? (
|
||||
String(row[col])
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-foreground truncate">{formatValue(row[col])}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -924,33 +1051,87 @@ export function FlowWidget({
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedStepData.map((row, pageIndex) => {
|
||||
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
|
||||
return (
|
||||
<TableRow
|
||||
key={actualIndex}
|
||||
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
|
||||
>
|
||||
{allowDataMove && (
|
||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(actualIndex)}
|
||||
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||
/>
|
||||
{groupByColumns.length > 0 && groupedData.length > 0 ? (
|
||||
// 그룹화된 렌더링
|
||||
groupedData.flatMap((group) => {
|
||||
const isCollapsed = collapsedGroups.has(group.groupKey);
|
||||
const groupRows = [
|
||||
<TableRow key={`group-${group.groupKey}`}>
|
||||
<TableCell
|
||||
colSpan={stepDataColumns.length + (allowDataMove ? 1 : 0)}
|
||||
className="bg-muted/50 border-b"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-3 p-2 cursor-pointer hover:bg-muted"
|
||||
onClick={() => toggleGroupCollapse(group.groupKey)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-medium text-sm flex-1">{group.groupKey}</span>
|
||||
<span className="text-muted-foreground text-xs">({group.count}건)</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
||||
{row[col] !== null && row[col] !== undefined ? (
|
||||
String(row[col])
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableRow>,
|
||||
];
|
||||
|
||||
if (!isCollapsed) {
|
||||
const dataRows = group.items.map((row, itemIndex) => {
|
||||
const actualIndex = displayData.indexOf(row);
|
||||
return (
|
||||
<TableRow
|
||||
key={`${group.groupKey}-${itemIndex}`}
|
||||
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
|
||||
>
|
||||
{allowDataMove && (
|
||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(actualIndex)}
|
||||
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
||||
{formatValue(row[col])}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
groupRows.push(...dataRows);
|
||||
}
|
||||
|
||||
return groupRows;
|
||||
})
|
||||
) : (
|
||||
// 일반 렌더링 (그룹 없음)
|
||||
paginatedStepData.map((row, pageIndex) => {
|
||||
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
|
||||
return (
|
||||
<TableRow
|
||||
key={actualIndex}
|
||||
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
|
||||
>
|
||||
{allowDataMove && (
|
||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(actualIndex)}
|
||||
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
||||
{formatValue(row[col])}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
|
@ -964,7 +1145,7 @@ export function FlowWidget({
|
|||
{/* 왼쪽: 페이지 정보 + 페이지 크기 선택 */}
|
||||
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
|
||||
<div className="text-muted-foreground text-xs sm:text-sm">
|
||||
페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length}건)
|
||||
페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length.toLocaleString("ko-KR")}건)
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">표시 개수:</span>
|
||||
|
|
@ -1150,6 +1331,63 @@ export function FlowWidget({
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 🆕 그룹 설정 다이얼로그 */}
|
||||
<Dialog open={isGroupSettingOpen} onOpenChange={setIsGroupSettingOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">그룹 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 컬럼 목록 */}
|
||||
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
||||
{stepDataColumns.map((col) => (
|
||||
<div key={col} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
||||
<Checkbox
|
||||
id={`group-${col}`}
|
||||
checked={groupByColumns.includes(col)}
|
||||
onCheckedChange={() => toggleGroupColumn(col)}
|
||||
/>
|
||||
<Label htmlFor={`group-${col}`} className="flex-1 cursor-pointer text-xs font-normal sm:text-sm">
|
||||
{columnLabels[col] || col}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 선택된 그룹 안내 */}
|
||||
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-xs">
|
||||
{groupByColumns.length === 0 ? (
|
||||
<span>그룹화할 컬럼을 선택하세요</span>
|
||||
) : (
|
||||
<span>
|
||||
선택된 그룹:{" "}
|
||||
<span className="text-primary font-semibold">
|
||||
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsGroupSettingOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={saveGroupSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
적용
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,6 +242,12 @@ export const tableTypeApi = {
|
|||
return data.columns || data || [];
|
||||
},
|
||||
|
||||
// 컬럼 입력 타입 정보 조회
|
||||
getColumnInputTypes: async (tableName: string): Promise<any[]> => {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/web-types`);
|
||||
return response.data.data || [];
|
||||
},
|
||||
|
||||
// 컬럼 웹 타입 설정
|
||||
setColumnWebType: async (
|
||||
tableName: string,
|
||||
|
|
|
|||
|
|
@ -19,9 +19,12 @@ import {
|
|||
TableIcon,
|
||||
Settings,
|
||||
X,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -35,6 +38,18 @@ import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearc
|
|||
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
||||
import { CardModeRenderer } from "./CardModeRenderer";
|
||||
|
||||
// ========================================
|
||||
// 인터페이스
|
||||
// ========================================
|
||||
|
||||
// 그룹화된 데이터 인터페이스
|
||||
interface GroupedData {
|
||||
groupKey: string;
|
||||
groupValues: Record<string, any>;
|
||||
items: any[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 캐시 및 유틸리티
|
||||
// ========================================
|
||||
|
|
@ -244,12 +259,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
||||
const [isAllSelected, setIsAllSelected] = useState(false);
|
||||
const hasInitializedWidths = useRef(false);
|
||||
const isResizing = useRef(false);
|
||||
|
||||
// 필터 설정 관련 상태
|
||||
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
|
||||
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
|
||||
|
||||
// 그룹 설정 관련 상태
|
||||
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false);
|
||||
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
|
||||
enableBatchLoading: true,
|
||||
preloadCommonCodes: true,
|
||||
|
|
@ -284,20 +308,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
|
||||
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
|
||||
|
||||
// 컬럼 입력 타입 정보 가져오기
|
||||
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
|
||||
const inputTypeMap: Record<string, string> = {};
|
||||
inputTypes.forEach((col: any) => {
|
||||
inputTypeMap[col.columnName] = col.inputType;
|
||||
});
|
||||
|
||||
tableColumnCache.set(cacheKey, {
|
||||
columns,
|
||||
inputTypes,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const labels: Record<string, string> = {};
|
||||
const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
|
||||
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
||||
|
||||
columns.forEach((col: any) => {
|
||||
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
||||
meta[col.columnName] = {
|
||||
webType: col.webType,
|
||||
codeCategory: col.codeCategory,
|
||||
inputType: inputTypeMap[col.columnName],
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -642,12 +675,46 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
|
||||
const meta = columnMeta[column.columnName];
|
||||
if (meta?.webType && meta?.codeCategory) {
|
||||
const convertedValue = optimizedConvertCode(value, meta.codeCategory);
|
||||
if (convertedValue !== value) return convertedValue;
|
||||
|
||||
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
||||
const inputType = meta?.inputType || column.inputType;
|
||||
|
||||
// 코드 타입: 코드 값 → 코드명 변환
|
||||
if (inputType === "code" && meta?.codeCategory && value) {
|
||||
try {
|
||||
// optimizedConvertCode(categoryCode, codeValue) 순서 주의!
|
||||
const convertedValue = optimizedConvertCode(meta.codeCategory, value);
|
||||
// 변환에 성공했으면 변환된 코드명 반환
|
||||
if (convertedValue && convertedValue !== value) {
|
||||
return convertedValue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeCategory}, 값: ${value}`, error);
|
||||
}
|
||||
// 변환 실패 시 원본 코드 값 반환
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// 숫자 타입 포맷팅
|
||||
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");
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
switch (column.format) {
|
||||
case "number":
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (!isNaN(numValue)) {
|
||||
return numValue.toLocaleString("ko-KR");
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
case "date":
|
||||
if (value) {
|
||||
try {
|
||||
|
|
@ -681,9 +748,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return `tableList_filterSettings_${tableConfig.selectedTable}`;
|
||||
}, [tableConfig.selectedTable]);
|
||||
|
||||
// 그룹 설정 localStorage 키 생성
|
||||
const groupSettingKey = useMemo(() => {
|
||||
if (!tableConfig.selectedTable) return null;
|
||||
return `tableList_groupSettings_${tableConfig.selectedTable}`;
|
||||
}, [tableConfig.selectedTable]);
|
||||
|
||||
// 저장된 필터 설정 불러오기
|
||||
useEffect(() => {
|
||||
if (!filterSettingKey) return;
|
||||
if (!filterSettingKey || visibleColumns.length === 0) return;
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(filterSettingKey);
|
||||
|
|
@ -691,17 +764,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const savedFilters = JSON.parse(saved);
|
||||
setVisibleFilterColumns(new Set(savedFilters));
|
||||
} else {
|
||||
// 초기값: 모든 필터 표시
|
||||
const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName);
|
||||
setVisibleFilterColumns(new Set(allFilters));
|
||||
// 초기값: 빈 Set (아무것도 선택 안 함)
|
||||
setVisibleFilterColumns(new Set());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("필터 설정 불러오기 실패:", error);
|
||||
// 기본값으로 모든 필터 표시
|
||||
const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName);
|
||||
setVisibleFilterColumns(new Set(allFilters));
|
||||
setVisibleFilterColumns(new Set());
|
||||
}
|
||||
}, [filterSettingKey, tableConfig.filter?.filters]);
|
||||
}, [filterSettingKey, visibleColumns]);
|
||||
|
||||
// 필터 설정 저장
|
||||
const saveFilterSettings = useCallback(() => {
|
||||
|
|
@ -710,12 +780,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
try {
|
||||
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
|
||||
setIsFilterSettingOpen(false);
|
||||
toast.success("검색 필터 설정이 저장되었습니다");
|
||||
|
||||
// 검색 값 초기화
|
||||
setSearchValues({});
|
||||
} catch (error) {
|
||||
console.error("필터 설정 저장 실패:", error);
|
||||
toast.error("설정 저장에 실패했습니다");
|
||||
}
|
||||
}, [filterSettingKey, visibleFilterColumns]);
|
||||
|
||||
// 필터 토글
|
||||
// 필터 컬럼 토글
|
||||
const toggleFilterVisibility = useCallback((columnName: string) => {
|
||||
setVisibleFilterColumns((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
|
|
@ -728,10 +803,129 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}, []);
|
||||
|
||||
// 표시할 필터 목록
|
||||
// 전체 선택/해제
|
||||
const toggleAllFilters = useCallback(() => {
|
||||
const filterableColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__");
|
||||
const columnNames = filterableColumns.map((col) => col.columnName);
|
||||
|
||||
if (visibleFilterColumns.size === columnNames.length) {
|
||||
// 전체 해제
|
||||
setVisibleFilterColumns(new Set());
|
||||
} else {
|
||||
// 전체 선택
|
||||
setVisibleFilterColumns(new Set(columnNames));
|
||||
}
|
||||
}, [visibleFilterColumns, visibleColumns]);
|
||||
|
||||
// 표시할 필터 목록 (선택된 컬럼만)
|
||||
const activeFilters = useMemo(() => {
|
||||
return (tableConfig.filter?.filters || []).filter((f) => visibleFilterColumns.has(f.columnName));
|
||||
}, [tableConfig.filter?.filters, visibleFilterColumns]);
|
||||
return visibleColumns
|
||||
.filter((col) => col.columnName !== "__checkbox__" && visibleFilterColumns.has(col.columnName))
|
||||
.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
||||
type: col.format || "text",
|
||||
}));
|
||||
}, [visibleColumns, visibleFilterColumns, columnLabels]);
|
||||
|
||||
// 그룹 설정 저장
|
||||
const saveGroupSettings = useCallback(() => {
|
||||
if (!groupSettingKey) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
|
||||
setIsGroupSettingOpen(false);
|
||||
toast.success("그룹 설정이 저장되었습니다");
|
||||
} catch (error) {
|
||||
console.error("그룹 설정 저장 실패:", error);
|
||||
toast.error("설정 저장에 실패했습니다");
|
||||
}
|
||||
}, [groupSettingKey, groupByColumns]);
|
||||
|
||||
// 그룹 컬럼 토글
|
||||
const toggleGroupColumn = useCallback((columnName: string) => {
|
||||
setGroupByColumns((prev) => {
|
||||
if (prev.includes(columnName)) {
|
||||
return prev.filter((col) => col !== columnName);
|
||||
} else {
|
||||
return [...prev, columnName];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 그룹 펼치기/접기 토글
|
||||
const toggleGroupCollapse = useCallback((groupKey: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(groupKey)) {
|
||||
newSet.delete(groupKey);
|
||||
} else {
|
||||
newSet.add(groupKey);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 그룹 해제
|
||||
const clearGrouping = useCallback(() => {
|
||||
setGroupByColumns([]);
|
||||
setCollapsedGroups(new Set());
|
||||
if (groupSettingKey) {
|
||||
localStorage.removeItem(groupSettingKey);
|
||||
}
|
||||
toast.success("그룹이 해제되었습니다");
|
||||
}, [groupSettingKey]);
|
||||
|
||||
// 데이터 그룹화
|
||||
const groupedData = useMemo((): GroupedData[] => {
|
||||
if (groupByColumns.length === 0 || data.length === 0) return [];
|
||||
|
||||
const grouped = new Map<string, any[]>();
|
||||
|
||||
data.forEach((item) => {
|
||||
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
||||
const keyParts = groupByColumns.map((col) => {
|
||||
const value = item[col];
|
||||
const label = columnLabels[col] || col;
|
||||
return `${label}:${value !== null && value !== undefined ? value : "-"}`;
|
||||
});
|
||||
const groupKey = keyParts.join(" > ");
|
||||
|
||||
if (!grouped.has(groupKey)) {
|
||||
grouped.set(groupKey, []);
|
||||
}
|
||||
grouped.get(groupKey)!.push(item);
|
||||
});
|
||||
|
||||
return Array.from(grouped.entries()).map(([groupKey, items]) => {
|
||||
const groupValues: Record<string, any> = {};
|
||||
groupByColumns.forEach((col) => {
|
||||
groupValues[col] = items[0]?.[col];
|
||||
});
|
||||
|
||||
return {
|
||||
groupKey,
|
||||
groupValues,
|
||||
items,
|
||||
count: items.length,
|
||||
};
|
||||
});
|
||||
}, [data, groupByColumns, columnLabels]);
|
||||
|
||||
// 저장된 그룹 설정 불러오기
|
||||
useEffect(() => {
|
||||
if (!groupSettingKey || visibleColumns.length === 0) return;
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(groupSettingKey);
|
||||
if (saved) {
|
||||
const savedGroups = JSON.parse(saved);
|
||||
setGroupByColumns(savedGroups);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹 설정 불러오기 실패:", error);
|
||||
}
|
||||
}, [groupSettingKey, visibleColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchColumnLabels();
|
||||
|
|
@ -763,6 +957,38 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
}, [tableConfig.refreshInterval, isDesignMode]);
|
||||
|
||||
// 초기 컬럼 너비 측정 (한 번만)
|
||||
useEffect(() => {
|
||||
if (!hasInitializedWidths.current && visibleColumns.length > 0) {
|
||||
// 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정
|
||||
const timer = setTimeout(() => {
|
||||
const newWidths: Record<string, number> = {};
|
||||
let hasAnyWidth = false;
|
||||
|
||||
visibleColumns.forEach((column) => {
|
||||
// 체크박스 컬럼은 제외 (고정 48px)
|
||||
if (column.columnName === "__checkbox__") return;
|
||||
|
||||
const thElement = columnRefs.current[column.columnName];
|
||||
if (thElement) {
|
||||
const measuredWidth = thElement.offsetWidth;
|
||||
if (measuredWidth > 0) {
|
||||
newWidths[column.columnName] = measuredWidth;
|
||||
hasAnyWidth = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasAnyWidth) {
|
||||
setColumnWidths(newWidths);
|
||||
hasInitializedWidths.current = true;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
// ========================================
|
||||
// 페이지네이션 JSX
|
||||
// ========================================
|
||||
|
|
@ -872,14 +1098,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (tableConfig.stickyHeader && !isDesignMode) {
|
||||
return (
|
||||
<div {...domProps}>
|
||||
{tableConfig.showHeader && (
|
||||
<div className="px-4 py-3 border-b border-border sm:px-6 sm:py-4">
|
||||
<h2 className="text-base font-semibold text-foreground sm:text-lg">
|
||||
{tableConfig.title || tableLabel || finalSelectedTable}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tableConfig.filter?.enabled && (
|
||||
<div className="px-4 py-3 border-b border-border sm:px-6 sm:py-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||
|
|
@ -892,15 +1110,52 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onClear={handleClearAdvancedFilters}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsFilterSettingOpen(true)}
|
||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsFilterSettingOpen(true)}
|
||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
필터 설정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsGroupSettingOpen(true)}
|
||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||
>
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
그룹 설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 그룹 표시 배지 */}
|
||||
{groupByColumns.length > 0 && (
|
||||
<div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6">
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<span className="text-muted-foreground">그룹:</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{groupByColumns.map((col, idx) => (
|
||||
<span key={col} className="flex items-center">
|
||||
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
||||
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
||||
{columnLabels[col] || col}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={clearGrouping}
|
||||
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
||||
title="그룹 해제"
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
필터 설정
|
||||
</Button>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -935,15 +1190,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return (
|
||||
<>
|
||||
<div {...domProps}>
|
||||
{/* 헤더 */}
|
||||
{tableConfig.showHeader && (
|
||||
<div className="px-4 py-3 border-b border-border flex-shrink-0 sm:px-6 sm:py-4">
|
||||
<h2 className="text-base font-semibold text-foreground sm:text-lg">
|
||||
{tableConfig.title || tableLabel || finalSelectedTable}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 */}
|
||||
{tableConfig.filter?.enabled && (
|
||||
<div className="px-4 py-3 border-b border-border flex-shrink-0 sm:px-6 sm:py-4">
|
||||
|
|
@ -957,15 +1203,52 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onClear={handleClearAdvancedFilters}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsFilterSettingOpen(true)}
|
||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsFilterSettingOpen(true)}
|
||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
필터 설정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsGroupSettingOpen(true)}
|
||||
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
||||
>
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
그룹 설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 그룹 표시 배지 */}
|
||||
{groupByColumns.length > 0 && (
|
||||
<div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6">
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<span className="text-muted-foreground">그룹:</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{groupByColumns.map((col, idx) => (
|
||||
<span key={col} className="flex items-center">
|
||||
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
||||
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
||||
{columnLabels[col] || col}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={clearGrouping}
|
||||
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
||||
title="그룹 해제"
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
필터 설정
|
||||
</Button>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -982,38 +1265,110 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
style={{
|
||||
borderCollapse: "collapse",
|
||||
width: "100%",
|
||||
tableLayout: "fixed",
|
||||
}}
|
||||
>
|
||||
{/* 헤더 (sticky) */}
|
||||
<thead
|
||||
className="sticky top-0 z-10 bg-background"
|
||||
className="sticky top-0 z-10"
|
||||
>
|
||||
<tr className="h-10 border-b border-border sm:h-12">
|
||||
{visibleColumns.map((column) => (
|
||||
<th
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"h-10 px-2 py-2 text-xs font-semibold text-foreground overflow-hidden text-ellipsis bg-background sm:h-12 sm:px-6 sm:py-3 sm:text-sm sm:whitespace-nowrap",
|
||||
column.sortable && "cursor-pointer"
|
||||
)}
|
||||
style={{
|
||||
textAlign: column.align || "left",
|
||||
width: `${100 / visibleColumns.length}%`, // 컬럼 수에 따라 균등 분배
|
||||
}}
|
||||
onClick={() => column.sortable && handleSort(column.columnName)}
|
||||
>
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
renderCheckboxHeader()
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
||||
{column.sortable && sortColumn === column.columnName && (
|
||||
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<tr className="h-10 border-b-2 border-primary/20 bg-gradient-to-b from-muted/50 to-muted sm:h-12">
|
||||
{visibleColumns.map((column, columnIndex) => {
|
||||
const columnWidth = columnWidths[column.columnName];
|
||||
|
||||
return (
|
||||
<th
|
||||
key={column.columnName}
|
||||
ref={(el) => (columnRefs.current[column.columnName] = el)}
|
||||
className={cn(
|
||||
"relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-12 sm:text-sm",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3",
|
||||
column.sortable && "cursor-pointer hover:bg-muted/70 transition-colors"
|
||||
)}
|
||||
style={{
|
||||
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
|
||||
width: column.columnName === "__checkbox__" ? '48px' : (columnWidth ? `${columnWidth}px` : undefined),
|
||||
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||
userSelect: 'none'
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isResizing.current) return;
|
||||
if (column.sortable) handleSort(column.columnName);
|
||||
}}
|
||||
>
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
renderCheckboxHeader()
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
||||
{column.sortable && sortColumn === column.columnName && (
|
||||
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 리사이즈 핸들 (체크박스 제외) */}
|
||||
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
|
||||
style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
|
||||
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const thElement = columnRefs.current[column.columnName];
|
||||
if (!thElement) return;
|
||||
|
||||
isResizing.current = true;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startWidth = columnWidth || thElement.offsetWidth;
|
||||
|
||||
// 드래그 중 텍스트 선택 방지
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
moveEvent.preventDefault();
|
||||
|
||||
const diff = moveEvent.clientX - startX;
|
||||
const newWidth = Math.max(80, startWidth + diff);
|
||||
|
||||
// 직접 DOM 스타일 변경 (리렌더링 없음)
|
||||
if (thElement) {
|
||||
thElement.style.width = `${newWidth}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// 최종 너비를 state에 저장
|
||||
if (thElement) {
|
||||
const finalWidth = Math.max(80, thElement.offsetWidth);
|
||||
setColumnWidths(prev => ({ ...prev, [column.columnName]: finalWidth }));
|
||||
}
|
||||
|
||||
// 텍스트 선택 복원
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
|
||||
// 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록)
|
||||
setTimeout(() => {
|
||||
isResizing.current = false;
|
||||
}, 100);
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
|
|
@ -1049,7 +1404,81 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : groupByColumns.length > 0 && groupedData.length > 0 ? (
|
||||
// 그룹화된 렌더링
|
||||
groupedData.map((group) => {
|
||||
const isCollapsed = collapsedGroups.has(group.groupKey);
|
||||
return (
|
||||
<React.Fragment key={group.groupKey}>
|
||||
{/* 그룹 헤더 */}
|
||||
<tr>
|
||||
<td
|
||||
colSpan={visibleColumns.length}
|
||||
className="bg-muted/50 border-b border-border sticky top-[48px] z-[5]"
|
||||
style={{ top: "48px" }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted"
|
||||
onClick={() => toggleGroupCollapse(group.groupKey)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-medium text-sm flex-1">{group.groupKey}</span>
|
||||
<span className="text-muted-foreground text-xs">({group.count}건)</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/* 그룹 데이터 */}
|
||||
{!isCollapsed &&
|
||||
group.items.map((row, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
draggable={!isDesignMode}
|
||||
onDragStart={(e) => handleRowDragStart(e, row, index)}
|
||||
onDragEnd={handleRowDragEnd}
|
||||
className={cn(
|
||||
"h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16"
|
||||
)}
|
||||
onClick={() => handleRowClick(row)}
|
||||
>
|
||||
{visibleColumns.map((column) => {
|
||||
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||||
const cellValue = row[mappedColumnName];
|
||||
|
||||
const meta = columnMeta[column.columnName];
|
||||
const inputType = meta?.inputType || column.inputType;
|
||||
const isNumeric = inputType === "number" || inputType === "decimal";
|
||||
|
||||
return (
|
||||
<td
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"h-14 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-16 sm:text-sm",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3"
|
||||
)}
|
||||
style={{
|
||||
textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")),
|
||||
width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`,
|
||||
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||
}}
|
||||
>
|
||||
{column.columnName === "__checkbox__"
|
||||
? renderCheckboxCell(row, index)
|
||||
: formatCellValue(cellValue, column, row)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// 일반 렌더링 (그룹 없음)
|
||||
data.map((row, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
|
|
@ -1065,15 +1494,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||||
const cellValue = row[mappedColumnName];
|
||||
|
||||
const meta = columnMeta[column.columnName];
|
||||
const inputType = meta?.inputType || column.inputType;
|
||||
const isNumeric = inputType === "number" || inputType === "decimal";
|
||||
|
||||
return (
|
||||
<td
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"h-14 px-2 py-2 text-xs text-foreground overflow-hidden text-ellipsis sm:h-16 sm:px-6 sm:py-3 sm:text-sm sm:whitespace-nowrap"
|
||||
"h-14 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-16 sm:text-sm",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3"
|
||||
)}
|
||||
style={{
|
||||
textAlign: column.align || "left",
|
||||
width: `${100 / visibleColumns.length}%`, // 컬럼 수에 따라 균등 분배
|
||||
textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")),
|
||||
width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`,
|
||||
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
||||
}}
|
||||
>
|
||||
{column.columnName === "__checkbox__"
|
||||
|
|
@ -1100,26 +1536,63 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">검색 필터 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
표시할 검색 필터를 선택하세요. 선택하지 않은 필터는 숨겨집니다.
|
||||
검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] space-y-3 overflow-y-auto sm:space-y-4">
|
||||
{(tableConfig.filter?.filters || []).map((filter) => (
|
||||
<div className="flex items-center gap-3 rounded p-2 hover:bg-muted">
|
||||
<Checkbox
|
||||
id={`filter-${filter.columnName}`}
|
||||
checked={visibleFilterColumns.has(filter.columnName)}
|
||||
onCheckedChange={() => toggleFilterVisibility(filter.columnName)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`filter-${filter.columnName}`}
|
||||
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
|
||||
>
|
||||
{columnLabels[filter.columnName] || filter.label || filter.columnName}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 전체 선택/해제 */}
|
||||
<div className="bg-muted/50 flex items-center gap-3 rounded border p-3">
|
||||
<Checkbox
|
||||
id="select-all-filters"
|
||||
checked={
|
||||
visibleFilterColumns.size ===
|
||||
visibleColumns.filter((col) => col.columnName !== "__checkbox__").length &&
|
||||
visibleColumns.filter((col) => col.columnName !== "__checkbox__").length > 0
|
||||
}
|
||||
onCheckedChange={toggleAllFilters}
|
||||
/>
|
||||
<Label htmlFor="select-all-filters" className="flex-1 cursor-pointer text-xs font-semibold sm:text-sm">
|
||||
전체 선택/해제
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{visibleFilterColumns.size} / {visibleColumns.filter((col) => col.columnName !== "__checkbox__").length}
|
||||
개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 목록 */}
|
||||
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
||||
{visibleColumns
|
||||
.filter((col) => col.columnName !== "__checkbox__")
|
||||
.map((col) => (
|
||||
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
||||
<Checkbox
|
||||
id={`filter-${col.columnName}`}
|
||||
checked={visibleFilterColumns.has(col.columnName)}
|
||||
onCheckedChange={() => toggleFilterVisibility(col.columnName)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`filter-${col.columnName}`}
|
||||
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
|
||||
>
|
||||
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 선택된 컬럼 개수 안내 */}
|
||||
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-center text-xs">
|
||||
{visibleFilterColumns.size === 0 ? (
|
||||
<span>검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요</span>
|
||||
) : (
|
||||
<span>
|
||||
총 <span className="text-primary font-semibold">{visibleFilterColumns.size}개</span>의 검색 필터가
|
||||
표시됩니다
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
|
|
@ -1136,6 +1609,68 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 그룹 설정 다이얼로그 */}
|
||||
<Dialog open={isGroupSettingOpen} onOpenChange={setIsGroupSettingOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">그룹 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 컬럼 목록 */}
|
||||
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
||||
{visibleColumns
|
||||
.filter((col) => col.columnName !== "__checkbox__")
|
||||
.map((col) => (
|
||||
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
||||
<Checkbox
|
||||
id={`group-${col.columnName}`}
|
||||
checked={groupByColumns.includes(col.columnName)}
|
||||
onCheckedChange={() => toggleGroupColumn(col.columnName)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`group-${col.columnName}`}
|
||||
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
|
||||
>
|
||||
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 선택된 그룹 안내 */}
|
||||
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-xs">
|
||||
{groupByColumns.length === 0 ? (
|
||||
<span>그룹화할 컬럼을 선택하세요</span>
|
||||
) : (
|
||||
<span>
|
||||
선택된 그룹:{" "}
|
||||
<span className="text-primary font-semibold">
|
||||
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsGroupSettingOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={saveGroupSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
적용
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -234,9 +234,13 @@ export class ButtonActionExecutor {
|
|||
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
||||
}
|
||||
|
||||
// 테이블과 플로우 모두 새로고침
|
||||
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
|
||||
context.onRefresh?.();
|
||||
context.onFlowRefresh?.();
|
||||
|
||||
// 저장 성공 후 EditModal 닫기 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("저장 오류:", error);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,365 @@
|
|||
# 테이블 그룹핑 기능 구현 계획서
|
||||
|
||||
## 📋 개요
|
||||
|
||||
테이블 리스트 컴포넌트와 플로우 위젯에 그룹핑 기능을 추가하여, 사용자가 선택한 컬럼(들)을 기준으로 데이터를 그룹화하여 표시합니다.
|
||||
|
||||
## 🎯 핵심 요구사항
|
||||
|
||||
### 1. 기능 요구사항
|
||||
- ✅ 그룹핑할 컬럼을 다중 선택 가능
|
||||
- ✅ 선택한 컬럼 순서대로 계층적 그룹화
|
||||
- ✅ 그룹 헤더에 그룹 정보와 데이터 개수 표시
|
||||
- ✅ 그룹 펼치기/접기 기능
|
||||
- ✅ localStorage에 그룹 설정 저장/복원
|
||||
- ✅ 그룹 해제 기능
|
||||
|
||||
### 2. 적용 대상
|
||||
- TableListComponent (`frontend/lib/registry/components/table-list/TableListComponent.tsx`)
|
||||
- FlowWidget (`frontend/components/screen/widgets/FlowWidget.tsx`)
|
||||
|
||||
## 🎨 UI 디자인
|
||||
|
||||
### 그룹 설정 다이얼로그
|
||||
|
||||
```tsx
|
||||
┌─────────────────────────────────────┐
|
||||
│ 📊 그룹 설정 │
|
||||
│ 데이터를 그룹화할 컬럼을 선택하세요 │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ [x] 통화 │
|
||||
│ [ ] 단위 │
|
||||
│ [ ] 품목코드 │
|
||||
│ [ ] 품목명 │
|
||||
│ [ ] 규격 │
|
||||
│ │
|
||||
│ 💡 선택된 그룹: 통화 │
|
||||
│ │
|
||||
├─────────────────────────────────────┤
|
||||
│ [취소] [적용] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 그룹화된 테이블 표시
|
||||
|
||||
```tsx
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 📦 판매품목 목록 총 3개 [🎨 그룹: 통화 ×] │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ▼ 통화: KRW > 단위: EA (2건) │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 품목코드 │ 품목명 │ 규격 │ 단위 │ │
|
||||
│ ├─────────────────────────────────────────────┤ │
|
||||
│ │ SALE-001 │ 볼트 M8x20 │ M8x20 │ EA │ │
|
||||
│ │ SALE-004 │ 스프링 와셔 │ M10 │ EA │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ▼ 통화: USD > 단위: EA (1건) │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ 품목코드 │ 품목명 │ 규격 │ 단위 │ │
|
||||
│ ├─────────────────────────────────────────────┤ │
|
||||
│ │ SALE-002 │ 너트 M8 │ M8 │ EA │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🔧 기술 구현
|
||||
|
||||
### 1. 상태 관리
|
||||
|
||||
```typescript
|
||||
// 그룹 설정 관련 상태
|
||||
const [groupByColumns, setGroupByColumns] = useState<string[]>([]); // 그룹화할 컬럼 목록
|
||||
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set()); // 접힌 그룹
|
||||
```
|
||||
|
||||
### 2. 데이터 그룹화 로직
|
||||
|
||||
```typescript
|
||||
interface GroupedData {
|
||||
groupKey: string; // "통화:KRW > 단위:EA"
|
||||
groupValues: Record<string, any>; // { 통화: "KRW", 단위: "EA" }
|
||||
items: any[]; // 그룹에 속한 데이터
|
||||
count: number; // 항목 개수
|
||||
}
|
||||
|
||||
const groupDataByColumns = (
|
||||
data: any[],
|
||||
groupColumns: string[]
|
||||
): GroupedData[] => {
|
||||
if (groupColumns.length === 0) return [];
|
||||
|
||||
const grouped = new Map<string, any[]>();
|
||||
|
||||
data.forEach(item => {
|
||||
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
||||
const keyParts = groupColumns.map(col => `${col}:${item[col] || '-'}`);
|
||||
const groupKey = keyParts.join(' > ');
|
||||
|
||||
if (!grouped.has(groupKey)) {
|
||||
grouped.set(groupKey, []);
|
||||
}
|
||||
grouped.get(groupKey)!.push(item);
|
||||
});
|
||||
|
||||
return Array.from(grouped.entries()).map(([groupKey, items]) => {
|
||||
const groupValues: Record<string, any> = {};
|
||||
groupColumns.forEach(col => {
|
||||
groupValues[col] = items[0]?.[col];
|
||||
});
|
||||
|
||||
return {
|
||||
groupKey,
|
||||
groupValues,
|
||||
items,
|
||||
count: items.length,
|
||||
};
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### 3. localStorage 저장/로드
|
||||
|
||||
```typescript
|
||||
// 저장 키
|
||||
const groupSettingKey = useMemo(() => {
|
||||
if (!tableConfig.selectedTable) return null;
|
||||
return `table-list-group-${tableConfig.selectedTable}`;
|
||||
}, [tableConfig.selectedTable]);
|
||||
|
||||
// 그룹 설정 저장
|
||||
const saveGroupSettings = useCallback(() => {
|
||||
if (!groupSettingKey) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
|
||||
setIsGroupSettingOpen(false);
|
||||
toast.success("그룹 설정이 저장되었습니다");
|
||||
} catch (error) {
|
||||
console.error("그룹 설정 저장 실패:", error);
|
||||
toast.error("설정 저장에 실패했습니다");
|
||||
}
|
||||
}, [groupSettingKey, groupByColumns]);
|
||||
|
||||
// 그룹 설정 로드
|
||||
useEffect(() => {
|
||||
if (!groupSettingKey || visibleColumns.length === 0) return;
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(groupSettingKey);
|
||||
if (saved) {
|
||||
const savedGroups = JSON.parse(saved);
|
||||
setGroupByColumns(savedGroups);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹 설정 불러오기 실패:", error);
|
||||
}
|
||||
}, [groupSettingKey, visibleColumns]);
|
||||
```
|
||||
|
||||
### 4. 그룹 헤더 렌더링
|
||||
|
||||
```tsx
|
||||
const renderGroupHeader = (group: GroupedData) => {
|
||||
const isCollapsed = collapsedGroups.has(group.groupKey);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-muted/50 flex items-center gap-3 border-b p-3 cursor-pointer hover:bg-muted"
|
||||
onClick={() => toggleGroupCollapse(group.groupKey)}
|
||||
>
|
||||
{/* 펼치기/접기 아이콘 */}
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
|
||||
{/* 그룹 정보 */}
|
||||
<span className="font-medium text-sm">
|
||||
{groupByColumns.map((col, idx) => (
|
||||
<span key={col}>
|
||||
{idx > 0 && <span className="text-muted-foreground"> > </span>}
|
||||
<span className="text-muted-foreground">{columnLabels[col] || col}:</span>
|
||||
{" "}
|
||||
<span className="text-foreground">{group.groupValues[col]}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
|
||||
{/* 항목 개수 */}
|
||||
<span className="text-muted-foreground text-xs ml-auto">
|
||||
({group.count}건)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 5. 그룹 설정 다이얼로그
|
||||
|
||||
```tsx
|
||||
<Dialog open={isGroupSettingOpen} onOpenChange={setIsGroupSettingOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">그룹 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 컬럼 목록 */}
|
||||
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
||||
{visibleColumns
|
||||
.filter((col) => col.columnName !== "__checkbox__")
|
||||
.map((col) => (
|
||||
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
||||
<Checkbox
|
||||
id={`group-${col.columnName}`}
|
||||
checked={groupByColumns.includes(col.columnName)}
|
||||
onCheckedChange={() => toggleGroupColumn(col.columnName)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`group-${col.columnName}`}
|
||||
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
|
||||
>
|
||||
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 선택된 그룹 안내 */}
|
||||
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-xs">
|
||||
{groupByColumns.length === 0 ? (
|
||||
<span>그룹화할 컬럼을 선택하세요</span>
|
||||
) : (
|
||||
<span>
|
||||
선택된 그룹: <span className="text-primary font-semibold">
|
||||
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsGroupSettingOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={saveGroupSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
적용
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
### 6. 그룹 해제 버튼
|
||||
|
||||
```tsx
|
||||
{/* 헤더 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2>{tableLabel}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 그룹 표시 배지 */}
|
||||
{groupByColumns.length > 0 && (
|
||||
<div className="bg-primary/10 text-primary flex items-center gap-2 rounded px-3 py-1 text-xs">
|
||||
<span>그룹: {groupByColumns.map(col => columnLabels[col] || col).join(", ")}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setGroupByColumns([]);
|
||||
localStorage.removeItem(groupSettingKey || "");
|
||||
toast.success("그룹이 해제되었습니다");
|
||||
}}
|
||||
className="hover:bg-primary/20 rounded p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 그룹 설정 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsGroupSettingOpen(true)}
|
||||
>
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
그룹 설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 📝 구현 순서
|
||||
|
||||
### Phase 1: TableListComponent 구현
|
||||
1. ✅ 상태 관리 추가 (groupByColumns, isGroupSettingOpen, collapsedGroups)
|
||||
2. ✅ 그룹화 로직 구현 (groupDataByColumns 함수)
|
||||
3. ✅ localStorage 저장/로드 로직
|
||||
4. ✅ 그룹 설정 다이얼로그 UI
|
||||
5. ✅ 그룹 헤더 렌더링
|
||||
6. ✅ 그룹별 데이터 렌더링
|
||||
7. ✅ 그룹 해제 기능
|
||||
|
||||
### Phase 2: FlowWidget 구현
|
||||
1. ✅ TableListComponent와 동일한 로직 적용
|
||||
2. ✅ 스텝 데이터에 그룹화 적용
|
||||
3. ✅ UI 통일성 유지
|
||||
|
||||
### Phase 3: 테스트 및 최적화
|
||||
1. ✅ 다중 그룹 계층 테스트
|
||||
2. ✅ 대량 데이터 성능 테스트
|
||||
3. ✅ localStorage 저장/복원 테스트
|
||||
4. ✅ 그룹 펼치기/접기 테스트
|
||||
|
||||
## 🎯 예상 효과
|
||||
|
||||
### 사용자 경험 개선
|
||||
- 데이터를 논리적으로 그룹화하여 가독성 향상
|
||||
- 대량 데이터를 효율적으로 탐색 가능
|
||||
- 사용자 정의 뷰 제공
|
||||
|
||||
### 데이터 분석 지원
|
||||
- 카테고리별 데이터 분석 용이
|
||||
- 통계 정보 제공 (그룹별 개수)
|
||||
- 계층적 데이터 구조 시각화
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### 성능 고려사항
|
||||
- 그룹화는 클라이언트 측에서 수행
|
||||
- 대량 데이터의 경우 성능 저하 가능
|
||||
- 필요시 서버 측 그룹화로 전환 검토
|
||||
|
||||
### 사용성
|
||||
- 그룹화 해제가 쉽게 가능해야 함
|
||||
- 그룹 설정이 직관적이어야 함
|
||||
- 모바일에서도 사용 가능한 UI
|
||||
|
||||
## 📊 구현 상태
|
||||
|
||||
- [ ] Phase 1: TableListComponent 구현
|
||||
- [ ] 상태 관리 추가
|
||||
- [ ] 그룹화 로직 구현
|
||||
- [ ] localStorage 연동
|
||||
- [ ] UI 구현
|
||||
- [ ] Phase 2: FlowWidget 구현
|
||||
- [ ] Phase 3: 테스트 및 최적화
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-11-03
|
||||
**버전**: 1.0
|
||||
**상태**: 구현 예정
|
||||
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
# 화면관리 및 테이블관리 시스템 개선사항 목록
|
||||
|
||||
## 문서 정보
|
||||
- **작성일**: 2025-11-03
|
||||
- **목적**: 사용자 피드백 기반 개선사항 정리
|
||||
- **우선순위**: 높음
|
||||
|
||||
---
|
||||
|
||||
## 1. 화면관리 (Screen Management) 개선사항
|
||||
|
||||
### 1.1 리스트 컬럼 Width 조절 기능
|
||||
**현재 문제**: 리스트 컬럼의 너비가 고정되어 있어 사용자가 조절할 수 없음
|
||||
|
||||
**요구사항**:
|
||||
- 사용자가 각 컬럼의 너비를 드래그로 조절할 수 있어야 함
|
||||
- 조절된 너비는 저장되어 다음 접속 시에도 유지되어야 함
|
||||
- 최소/최대 너비 제한 필요
|
||||
|
||||
**구현 방안**:
|
||||
- 컬럼 헤더에 리사이저 핸들 추가
|
||||
- `ComponentData` 인터페이스에 `columnWidths` 속성 추가
|
||||
- PropertiesPanel에서 개별 컬럼 너비 설정 UI 제공
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/components/screen/ScreenDesigner.tsx`
|
||||
- `frontend/components/screen/RealtimePreview.tsx`
|
||||
- `frontend/types/screen.ts`
|
||||
|
||||
---
|
||||
|
||||
### 1.2 되돌리기(Undo) 단축키 에러 수정
|
||||
**현재 문제**: 되돌리기 단축키(Ctrl+Z/Cmd+Z) 실행 시 에러 발생
|
||||
|
||||
**요구사항**:
|
||||
- 되돌리기 기능이 안정적으로 작동해야 함
|
||||
- 다시 실행(Redo) 기능도 함께 제공 (Ctrl+Y/Cmd+Shift+Z)
|
||||
|
||||
**구현 방안**:
|
||||
- 히스토리 스택 구현 (최대 50개 상태 저장)
|
||||
- `useUndo` 커스텀 훅 생성
|
||||
- 키보드 단축키 이벤트 리스너 추가
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/hooks/useUndo.ts` (신규 생성)
|
||||
- `frontend/components/screen/ScreenDesigner.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 1.3 리스트 헤더 스타일 개선
|
||||
**현재 문제**: 리스트 헤더가 눈에 잘 띄지 않음
|
||||
|
||||
**요구사항**:
|
||||
- 헤더가 시각적으로 구분되어야 함
|
||||
- 배경색, 폰트 굵기, 테두리 등으로 강조
|
||||
|
||||
**구현 방안**:
|
||||
- 헤더 기본 스타일 변경:
|
||||
- 배경색: `bg-muted` → `bg-primary/10`
|
||||
- 폰트: `font-medium` → `font-semibold`
|
||||
- 하단 테두리: `border-b-2 border-primary`
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/components/screen/RealtimePreview.tsx`
|
||||
- `frontend/components/screen-viewer/InteractiveScreenViewer.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 1.4 텍스트 줄바꿈 문제 방지
|
||||
**현재 문제**: 화면을 줄였을 때 텍스트가 2줄로 나뉘거나 깨지는 현상
|
||||
|
||||
**요구사항**:
|
||||
- 텍스트가 항상 1줄로 표시되어야 함
|
||||
- 긴 텍스트는 말줄임표(...) 처리
|
||||
|
||||
**구현 방안**:
|
||||
- 모든 텍스트 요소에 다음 클래스 적용:
|
||||
```tsx
|
||||
className="truncate whitespace-nowrap overflow-hidden"
|
||||
```
|
||||
- 툴팁으로 전체 텍스트 표시
|
||||
|
||||
**관련 파일**:
|
||||
- 모든 컴포넌트의 텍스트 렌더링 부분
|
||||
|
||||
---
|
||||
|
||||
### 1.5 수정 모달 자동 닫기
|
||||
**현재 문제**: 수정 완료 후 모달이 자동으로 닫히지 않음
|
||||
|
||||
**요구사항**:
|
||||
- 수정 완료 시 모달이 즉시 닫혀야 함
|
||||
- 성공 메시지 표시 후 닫기
|
||||
|
||||
**구현 방안**:
|
||||
```typescript
|
||||
const handleUpdate = async () => {
|
||||
const result = await updateData(formData);
|
||||
if (result.success) {
|
||||
toast.success("수정이 완료되었습니다");
|
||||
setIsModalOpen(false); // 모달 닫기
|
||||
refreshList(); // 목록 새로고침
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/components/screen-viewer/InteractiveScreenViewer.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 1.6 테이블 Align 조절 기능
|
||||
**현재 문제**: 테이블 컬럼의 정렬(align)을 사용자가 조절할 수 없음
|
||||
|
||||
**요구사항**:
|
||||
- 각 컬럼의 정렬을 left/center/right로 설정 가능해야 함
|
||||
- 숫자 타입은 기본적으로 right 정렬
|
||||
|
||||
**구현 방안**:
|
||||
- `TableColumnConfig` 인터페이스에 `align` 속성 추가
|
||||
- PropertiesPanel에서 정렬 선택 UI 제공
|
||||
- 컬럼 타입별 기본 정렬 설정
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/types/screen.ts`
|
||||
- `frontend/components/screen/PropertiesPanel.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 1.7 숫자 천 단위 콤마 표시
|
||||
**현재 문제**: 숫자가 콤마 없이 표시됨
|
||||
|
||||
**요구사항**:
|
||||
- 모든 숫자는 천 단위마다 콤마(,)를 찍어야 함
|
||||
- 예: 1000000 → 1,000,000
|
||||
|
||||
**구현 방안**:
|
||||
```typescript
|
||||
// 유틸리티 함수 생성
|
||||
export const formatNumber = (value: number | string): string => {
|
||||
const num = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return "0";
|
||||
return new Intl.NumberFormat("ko-KR").format(num);
|
||||
};
|
||||
```
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/lib/utils/numberFormat.ts` (신규 생성)
|
||||
- 모든 숫자 표시 컴포넌트
|
||||
|
||||
---
|
||||
|
||||
### 1.8 Drilldown UI 개선
|
||||
**현재 문제**: 화면이 횡으로 너무 길게 나열됨
|
||||
|
||||
**요구사항**:
|
||||
- 계층적 구조로 정보 표시
|
||||
- 펼치기/접기 기능으로 공간 절약
|
||||
|
||||
**구현 방안**:
|
||||
- Accordion 컴포넌트 활용
|
||||
- 탭 네비게이션 구조 적용
|
||||
- 마스터-디테일 레이아웃 패턴
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/components/screen/ScreenDesigner.tsx`
|
||||
- `frontend/components/ui/accordion.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 2. 테이블 관리 (Table Management) 개선사항
|
||||
|
||||
### 2.1 테이블 기본 정보 선택 기능
|
||||
**현재 문제**: 테이블 기본 정보를 사용자가 선택할 수 없음
|
||||
|
||||
**요구사항**:
|
||||
- 테이블 생성/수정 시 다음 정보를 선택 가능해야 함:
|
||||
- 테이블 타입 (마스터/트랜잭션/코드)
|
||||
- 카테고리
|
||||
- 로그 사용 여부
|
||||
- 버전 관리 여부
|
||||
- 소프트 삭제 여부
|
||||
|
||||
**구현 방안**:
|
||||
- `TableManagement.tsx`에 선택 UI 추가
|
||||
- `CREATE TABLE` DDL 자동 생성 시 옵션 반영
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/components/table/TableManagement.tsx`
|
||||
- `backend-node/src/controllers/tableController.ts`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 컬럼 추가 기능
|
||||
**현재 문제**: 기존 테이블에 새 컬럼을 추가하는 기능 부족
|
||||
|
||||
**요구사항**:
|
||||
- 테이블 수정 시 컬럼을 동적으로 추가할 수 있어야 함
|
||||
- `ALTER TABLE ADD COLUMN` DDL 자동 생성
|
||||
- 컬럼 순서 조정 기능
|
||||
|
||||
**구현 방안**:
|
||||
```typescript
|
||||
// 컬럼 추가 API
|
||||
POST /api/table-management/tables/:tableName/columns
|
||||
{
|
||||
"columnName": "new_column",
|
||||
"dataType": "VARCHAR(100)",
|
||||
"nullable": true,
|
||||
"defaultValue": null
|
||||
}
|
||||
```
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/components/table/TableManagement.tsx`
|
||||
- `backend-node/src/controllers/tableController.ts`
|
||||
- `backend-node/src/services/ddlExecutionService.ts`
|
||||
|
||||
---
|
||||
|
||||
### 2.3 테이블 복제 기능
|
||||
**현재 문제**: 기존 테이블의 구조를 재사용하기 어려움
|
||||
|
||||
**요구사항**:
|
||||
- 기존 테이블을 복제하여 새 테이블 생성
|
||||
- 다음 정보를 복사:
|
||||
- 컬럼 구조 (이름, 타입, 제약조건)
|
||||
- 인덱스 정의
|
||||
- 외래키 관계 (선택적)
|
||||
- 데이터는 복사하지 않음 (구조만)
|
||||
|
||||
**구현 방안**:
|
||||
```typescript
|
||||
// 테이블 복제 API
|
||||
POST /api/table-management/tables/:sourceTableName/clone
|
||||
{
|
||||
"newTableName": "cloned_table",
|
||||
"includeIndexes": true,
|
||||
"includeForeignKeys": false,
|
||||
"copyData": false
|
||||
}
|
||||
```
|
||||
|
||||
**구현 단계**:
|
||||
1. 원본 테이블 정보 조회 (INFORMATION_SCHEMA)
|
||||
2. DDL 스크립트 생성
|
||||
3. 새 테이블 생성
|
||||
4. 인덱스 및 제약조건 추가
|
||||
5. 감사 로그 기록
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/components/table/TableManagement.tsx`
|
||||
- `backend-node/src/controllers/tableController.ts`
|
||||
- `backend-node/src/services/ddlExecutionService.ts`
|
||||
|
||||
**참고 문서**:
|
||||
- `/Users/kimjuseok/ERP-node/테이블_복제_기능_구현_계획서.md`
|
||||
|
||||
---
|
||||
|
||||
### 2.4 채번 Rule 관리 기능
|
||||
**현재 문제**: 자동 채번 규칙을 사용자가 관리할 수 없음
|
||||
|
||||
**요구사항**:
|
||||
- 채번 규칙 생성/수정/삭제 UI
|
||||
- 규칙 형식:
|
||||
- 접두사 (예: "PROD-")
|
||||
- 날짜 포맷 (예: "YYYYMMDD")
|
||||
- 일련번호 자릿수 (예: 5자리 → 00001)
|
||||
- 구분자 (예: "-")
|
||||
- 예시: `PROD-20251103-00001`
|
||||
|
||||
**구현 방안**:
|
||||
```typescript
|
||||
interface NumberingRule {
|
||||
id: string;
|
||||
ruleName: string;
|
||||
prefix: string;
|
||||
dateFormat?: "YYYY" | "YYYYMM" | "YYYYMMDD" | "YYYYMMDD-HH";
|
||||
sequenceDigits: number;
|
||||
separator: string;
|
||||
resetPeriod: "none" | "daily" | "monthly" | "yearly";
|
||||
currentSequence: number;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
}
|
||||
```
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/components/table/NumberingRuleManagement.tsx` (신규 생성)
|
||||
- `backend-node/src/controllers/numberingRuleController.ts` (신규 생성)
|
||||
- `backend-node/src/services/numberingRuleService.ts` (신규 생성)
|
||||
|
||||
---
|
||||
|
||||
## 3. 제어 관리 (Flow Management) 개선사항
|
||||
|
||||
### 3.1 제목 클릭 시 노드 선택 해제
|
||||
**현재 문제**: 제목을 입력할 때 백스페이스를 누르면 노드가 삭제됨
|
||||
|
||||
**요구사항**:
|
||||
- 제목(플로우명) 입력란 클릭 시 노드 선택이 해제되어야 함
|
||||
- 백스페이스 키가 텍스트 입력으로만 작동해야 함
|
||||
|
||||
**구현 방안**:
|
||||
```typescript
|
||||
const handleTitleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // 이벤트 전파 중지
|
||||
setSelectedNodes([]); // 노드 선택 해제
|
||||
};
|
||||
|
||||
const handleTitleKeyDown = (e: React.KeyboardEvent) => {
|
||||
e.stopPropagation(); // 백스페이스 키가 노드 삭제로 전파되지 않도록
|
||||
};
|
||||
|
||||
<Input
|
||||
value={flowName}
|
||||
onClick={handleTitleClick}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
/>
|
||||
```
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/components/flow/FlowDesigner.tsx`
|
||||
- `frontend/components/flow/FlowCanvas.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 4. 우선순위 및 구현 일정
|
||||
|
||||
### 높음 (즉시 수정 필요)
|
||||
1. **되돌리기 단축키 에러 수정** - 기능 오류
|
||||
2. **수정 모달 자동 닫기** - 사용자 경험 저해
|
||||
3. **제어관리 제목 입력 문제** - 데이터 손실 위험
|
||||
4. **숫자 천 단위 콤마 표시** - 가독성 문제
|
||||
|
||||
### 중간 (2주 내 완료)
|
||||
5. **리스트 컬럼 Width 조절**
|
||||
6. **리스트 헤더 스타일 개선**
|
||||
7. **텍스트 줄바꿈 문제 방지**
|
||||
8. **테이블 Align 조절**
|
||||
9. **컬럼 추가 기능**
|
||||
|
||||
### 낮음 (기능 추가)
|
||||
10. **테이블 기본 정보 선택**
|
||||
11. **테이블 복제 기능**
|
||||
12. **Drilldown UI 개선**
|
||||
13. **채번 Rule 관리**
|
||||
|
||||
---
|
||||
|
||||
## 5. 테스트 계획
|
||||
|
||||
각 개선사항 완료 시 다음을 확인:
|
||||
|
||||
### 기능 테스트
|
||||
- [ ] 새 기능이 정상 작동함
|
||||
- [ ] 기존 기능에 영향 없음
|
||||
- [ ] 에러 처리가 적절함
|
||||
|
||||
### 사용자 경험 테스트
|
||||
- [ ] UI가 직관적임
|
||||
- [ ] 반응 속도가 빠름
|
||||
- [ ] 모바일/태블릿 대응
|
||||
|
||||
### 성능 테스트
|
||||
- [ ] 대량 데이터 처리 시 성능 저하 없음
|
||||
- [ ] 메모리 누수 없음
|
||||
|
||||
---
|
||||
|
||||
## 6. 참고 문서
|
||||
|
||||
- [화면관리 시스템 현황](화면관리_및_테이블관리_개선사항_목록.md)
|
||||
- [테이블 복제 기능 계획서](테이블_복제_기능_구현_계획서.md)
|
||||
- [Shadcn/ui 레이아웃 패턴](docs/shadcn-ui-레이아웃-패턴-분석-보고서.md)
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 작성자 | 내용 |
|
||||
|------|--------|------|
|
||||
| 2025-11-03 | 개발팀 | 초안 작성 |
|
||||
|
||||
Loading…
Reference in New Issue