diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index ba9dcdc1..374015ee 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -59,12 +59,56 @@ export class AuthController { logger.info(`- userName: ${userInfo.userName}`); logger.info(`- companyCode: ${userInfo.companyCode}`); + // 사용자의 첫 번째 접근 가능한 메뉴 조회 + let firstMenuPath: string | null = null; + try { + const { AdminService } = await import("../services/adminService"); + const paramMap = { + userId: loginResult.userInfo.userId, + userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN", + userType: loginResult.userInfo.userType, + userLang: "ko", + }; + + const menuList = await AdminService.getUserMenuList(paramMap); + logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); + + // 접근 가능한 첫 번째 메뉴 찾기 + // 조건: + // 1. LEV (레벨)이 2 이상 (최상위 폴더 제외) + // 2. MENU_URL이 있고 비어있지 않음 + // 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴 + const firstMenu = menuList.find((menu: any) => { + const level = menu.lev || menu.level; + const url = menu.menu_url || menu.url; + + return level >= 2 && url && url.trim() !== "" && url !== "#"; + }); + + if (firstMenu) { + firstMenuPath = firstMenu.menu_url || firstMenu.url; + logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, { + name: firstMenu.menu_name_kor || firstMenu.translated_name, + url: firstMenuPath, + level: firstMenu.lev || firstMenu.level, + seq: firstMenu.seq, + }); + } else { + logger.info( + "⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다." + ); + } + } catch (menuError) { + logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError); + } + res.status(200).json({ success: true, message: "로그인 성공", data: { userInfo, token: loginResult.token, + firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가 }, }); } else { diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index 7b10e071..a97c72e1 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -1,10 +1,10 @@ "use client"; -import React, { useEffect, useState } from "react"; +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 } from "lucide-react"; +import { AlertCircle, Loader2, ChevronUp, Filter, X } from "lucide-react"; import { getFlowById, getAllStepCounts, @@ -27,6 +27,16 @@ import { PaginationPrevious, } from "@/components/ui/pagination"; import { useFlowStepStore } from "@/stores/flowStepStore"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; interface FlowWidgetProps { component: FlowComponent; @@ -62,6 +72,13 @@ export function FlowWidget({ const [selectedRows, setSelectedRows] = useState>(new Set()); const [columnLabels, setColumnLabels] = useState>({}); // 컬럼명 -> 라벨 매핑 + // 🆕 검색 필터 관련 상태 + const [searchFilterColumns, setSearchFilterColumns] = useState>(new Set()); // 검색 필터로 사용할 컬럼 + const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); // 필터 설정 다이얼로그 + const [searchValues, setSearchValues] = useState>({}); // 검색 값 + const [allAvailableColumns, setAllAvailableColumns] = useState([]); // 전체 컬럼 목록 + const [filteredData, setFilteredData] = useState([]); // 필터링된 데이터 + /** * 🆕 컬럼 표시 결정 함수 * 1순위: 플로우 스텝 기본 설정 (displayConfig) @@ -97,6 +114,113 @@ export function FlowWidget({ // 🆕 플로우 컴포넌트 ID (버튼이 이 플로우를 참조할 때 사용) const flowComponentId = component.id; + // 🆕 localStorage 키 생성 + const filterSettingKey = useMemo(() => { + if (!flowId || selectedStepId === null) return null; + return `flowWidget_searchFilters_${flowId}_${selectedStepId}`; + }, [flowId, selectedStepId]); + + // 🆕 저장된 필터 설정 불러오기 + useEffect(() => { + if (!filterSettingKey || allAvailableColumns.length === 0) return; + + try { + const saved = localStorage.getItem(filterSettingKey); + if (saved) { + const savedFilters = JSON.parse(saved); + setSearchFilterColumns(new Set(savedFilters)); + } else { + // 초기값: 빈 필터 (사용자가 선택해야 함) + setSearchFilterColumns(new Set()); + } + } catch (error) { + console.error("필터 설정 불러오기 실패:", error); + setSearchFilterColumns(new Set()); + } + }, [filterSettingKey, allAvailableColumns]); + + // 🆕 필터 설정 저장 + const saveFilterSettings = useCallback(() => { + if (!filterSettingKey) return; + + try { + localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(searchFilterColumns))); + setIsFilterSettingOpen(false); + toast.success("검색 필터 설정이 저장되었습니다"); + + // 검색 값 초기화 + setSearchValues({}); + } catch (error) { + console.error("필터 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); + } + }, [filterSettingKey, searchFilterColumns]); + + // 🆕 필터 컬럼 토글 + const toggleFilterColumn = useCallback((columnName: string) => { + setSearchFilterColumns((prev) => { + const newSet = new Set(prev); + if (newSet.has(columnName)) { + newSet.delete(columnName); + } else { + newSet.add(columnName); + } + return newSet; + }); + }, []); + + // 🆕 전체 선택/해제 + const toggleAllFilters = useCallback(() => { + if (searchFilterColumns.size === allAvailableColumns.length) { + // 전체 해제 + setSearchFilterColumns(new Set()); + } else { + // 전체 선택 + setSearchFilterColumns(new Set(allAvailableColumns)); + } + }, [searchFilterColumns, allAvailableColumns]); + + // 🆕 검색 실행 + const handleSearch = useCallback(() => { + if (!stepData || stepData.length === 0) return; + + const filtered = stepData.filter((row) => { + // 모든 검색 조건을 만족하는지 확인 + return Array.from(searchFilterColumns).every((col) => { + const searchValue = searchValues[col]; + if (!searchValue || searchValue.trim() === "") return true; // 빈 값은 필터링하지 않음 + + const cellValue = row[col]; + if (cellValue === null || cellValue === undefined) return false; + + // 문자열로 변환하여 대소문자 무시 검색 + return String(cellValue).toLowerCase().includes(searchValue.toLowerCase()); + }); + }); + + setFilteredData(filtered); + console.log("🔍 검색 실행:", { + totalRows: stepData.length, + filteredRows: filtered.length, + searchValues, + }); + }, [stepData, searchFilterColumns, searchValues]); + + // 🆕 검색 초기화 + const handleClearSearch = useCallback(() => { + setSearchValues({}); + setFilteredData([]); + }, []); + + // 검색 값이 변경될 때마다 자동 검색 + useEffect(() => { + if (Object.keys(searchValues).length > 0) { + handleSearch(); + } else { + setFilteredData([]); + } + }, [searchValues, handleSearch]); + // 선택된 스텝의 데이터를 다시 로드하는 함수 const refreshStepData = async () => { if (!flowId) return; @@ -149,14 +273,18 @@ export function FlowWidget({ // 🆕 컬럼 추출 및 우선순위 적용 if (rows.length > 0) { const allColumns = Object.keys(rows[0]); + setAllAvailableColumns(allColumns); // 전체 컬럼 목록 저장 const visibleColumns = getVisibleColumns(selectedStepId, allColumns); setStepDataColumns(visibleColumns); } else { + setAllAvailableColumns([]); setStepDataColumns([]); } // 선택 초기화 setSelectedRows(new Set()); + setSearchValues({}); // 검색 값도 초기화 + setFilteredData([]); // 필터링된 데이터 초기화 onSelectedDataChange?.([], selectedStepId); } } catch (err: any) { @@ -242,6 +370,7 @@ export function FlowWidget({ setStepData(rows); if (rows.length > 0) { const allColumns = Object.keys(rows[0]); + setAllAvailableColumns(allColumns); // 전체 컬럼 목록 저장 // sortedSteps를 직접 전달하여 타이밍 이슈 해결 const visibleColumns = getVisibleColumns(firstStep.id, allColumns, sortedSteps); setStepDataColumns(visibleColumns); @@ -335,9 +464,11 @@ export function FlowWidget({ // 🆕 컬럼 추출 및 우선순위 적용 if (rows.length > 0) { const allColumns = Object.keys(rows[0]); + setAllAvailableColumns(allColumns); // 전체 컬럼 목록 저장 const visibleColumns = getVisibleColumns(stepId, allColumns); setStepDataColumns(visibleColumns); } else { + setAllAvailableColumns([]); setStepDataColumns([]); } } catch (err: any) { @@ -385,9 +516,12 @@ export function FlowWidget({ onSelectedDataChange?.(selectedData, selectedStepId); }; + // 🆕 표시할 데이터 결정 (필터링된 데이터 또는 전체 데이터) + const displayData = filteredData.length > 0 ? filteredData : stepData; + // 🆕 페이지네이션된 스텝 데이터 - const paginatedStepData = stepData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize); - const totalStepDataPages = Math.ceil(stepData.length / stepDataPageSize); + const paginatedStepData = displayData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize); + const totalStepDataPages = Math.ceil(displayData.length / stepDataPageSize); if (loading) { return ( @@ -513,15 +647,77 @@ export function FlowWidget({
{/* 헤더 - 자동 높이 */}
-

- {steps.find((s) => s.id === selectedStepId)?.stepName} -

-

- 총 {stepData.length}건의 데이터 - {selectedRows.size > 0 && ( - ({selectedRows.size}건 선택됨) +

+
+

+ {steps.find((s) => s.id === selectedStepId)?.stepName} +

+

+ 총 {stepData.length}건의 데이터 + {filteredData.length > 0 && ( + (필터링: {filteredData.length}건) + )} + {selectedRows.size > 0 && ( + ({selectedRows.size}건 선택됨) + )} +

+
+ + {/* 🆕 필터 설정 버튼 */} + {allAvailableColumns.length > 0 && ( + )} -

+
+ + {/* 🆕 검색 필터 입력 영역 */} + {searchFilterColumns.size > 0 && ( +
+
+
검색 필터
+ {Object.keys(searchValues).length > 0 && ( + + )} +
+ +
+ {Array.from(searchFilterColumns).map((col) => ( +
+ + + setSearchValues((prev) => ({ + ...prev, + [col]: e.target.value, + })) + } + placeholder={`${columnLabels[col] || col} 검색...`} + className="h-8 text-xs" + /> +
+ ))} +
+
+ )}
{/* 데이터 영역 - 고정 높이 + 스크롤 */} @@ -746,6 +942,76 @@ export function FlowWidget({ )}
)} + + {/* 🆕 검색 필터 설정 다이얼로그 */} + + + + 검색 필터 설정 + + 검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다. + + + +
+ {/* 전체 선택/해제 */} +
+ 0} + onCheckedChange={toggleAllFilters} + /> + + + {searchFilterColumns.size} / {allAvailableColumns.length}개 + +
+ + {/* 컬럼 목록 */} +
+ {allAvailableColumns.map((col) => ( +
+ toggleFilterColumn(col)} + /> + +
+ ))} +
+ + {/* 선택된 컬럼 개수 안내 */} +
+ {searchFilterColumns.size === 0 ? ( + 검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요 + ) : ( + + 총 {searchFilterColumns.size}개의 검색 필터가 + 표시됩니다 + + )} +
+
+ + + + + +
+
); } diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index 1a7513e9..09c32d5f 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -141,8 +141,18 @@ export const useLogin = () => { // 쿠키에도 저장 (미들웨어에서 사용) document.cookie = `authToken=${result.data.token}; path=/; max-age=86400; SameSite=Lax`; - // 로그인 성공 - router.push(AUTH_CONFIG.ROUTES.MAIN); + // 로그인 성공 - 첫 번째 접근 가능한 메뉴로 리다이렉트 + const firstMenuPath = result.data?.firstMenuPath; + + if (firstMenuPath) { + // 접근 가능한 메뉴가 있으면 해당 메뉴로 이동 + console.log("첫 번째 접근 가능한 메뉴로 이동:", firstMenuPath); + router.push(firstMenuPath); + } else { + // 접근 가능한 메뉴가 없으면 메인 페이지로 이동 + console.log("접근 가능한 메뉴가 없어 메인 페이지로 이동"); + router.push(AUTH_CONFIG.ROUTES.MAIN); + } } else { // 로그인 실패 setError(result.message || FORM_VALIDATION.MESSAGES.LOGIN_FAILED); diff --git a/frontend/types/auth.ts b/frontend/types/auth.ts index f1d5bbd8..cd8e65b6 100644 --- a/frontend/types/auth.ts +++ b/frontend/types/auth.ts @@ -10,7 +10,11 @@ export interface LoginFormData { export interface LoginResponse { success: boolean; message?: string; - data?: any; + data?: { + token?: string; + userInfo?: any; + firstMenuPath?: string | null; + }; errorCode?: string; }