From ce8b4ed68847e63b4ebe14a8410641213fb36131 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 3 Mar 2026 15:42:30 +0900 Subject: [PATCH] feat: Add menu icon support in menu management - Enhanced the menu management functionality by adding a new `menu_icon` field in the database schema, allowing for the storage of menu icons. - Updated the `saveMenu` and `updateMenu` functions in the admin controller to handle the new `menu_icon` field during menu creation and updates. - Modified the `AdminService` to include `MENU_ICON` in various queries, ensuring that the icon data is retrieved and processed correctly. - Integrated the `MenuIconPicker` component in the frontend to allow users to select and display menu icons in the `MenuFormModal`. - Updated the sidebar and layout components to utilize the new icon data, enhancing the visual representation of menus across the application. --- .../src/controllers/adminController.ts | 11 +- backend-node/src/services/adminService.ts | 22 +- frontend/components/admin/MenuFormModal.tsx | 18 +- frontend/components/admin/MenuIconPicker.tsx | 553 ++++++++++++++++++ frontend/components/layout/AppLayout.tsx | 12 +- frontend/components/layout/MainSidebar.tsx | 12 +- frontend/contexts/MenuContext.tsx | 4 +- frontend/lib/api/menu.ts | 7 +- frontend/types/menu.ts | 6 + 9 files changed, 620 insertions(+), 25 deletions(-) create mode 100644 frontend/components/admin/MenuIconPicker.tsx diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 7b3b1033..f2f2f3ee 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1120,8 +1120,8 @@ export async function saveMenu( `INSERT INTO menu_info ( objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, menu_desc, writer, regdate, status, - system_name, company_code, lang_key, lang_key_desc, screen_code - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + system_name, company_code, lang_key, lang_key_desc, screen_code, menu_icon + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING *`, [ objid, @@ -1140,6 +1140,7 @@ export async function saveMenu( menuData.langKey || null, menuData.langKeyDesc || null, screenCode, + menuData.menuIcon || null, ] ); @@ -1323,8 +1324,9 @@ export async function updateMenu( company_code = $10, lang_key = $11, lang_key_desc = $12, - screen_code = $13 - WHERE objid = $14 + screen_code = $13, + menu_icon = $14 + WHERE objid = $15 RETURNING *`, [ menuData.menuType ? Number(menuData.menuType) : null, @@ -1340,6 +1342,7 @@ export async function updateMenu( menuData.langKey || null, menuData.langKeyDesc || null, screenCode, + menuData.menuIcon || null, Number(menuId), ] ); diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index ef41012f..e5d0c1a0 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -227,7 +227,8 @@ export class AdminService { PATH, CYCLE, TRANSLATED_NAME, - TRANSLATED_DESC + TRANSLATED_DESC, + MENU_ICON ) AS ( SELECT 1 AS LEVEL, @@ -282,7 +283,8 @@ export class AdminService { AND MLT.lang_code = $1 LIMIT 1), MENU.MENU_DESC - ) + ), + MENU.MENU_ICON FROM MENU_INFO MENU WHERE ${menuTypeCondition} AND ${statusCondition} @@ -348,7 +350,8 @@ export class AdminService { AND MLT.lang_code = $1 LIMIT 1), MENU_SUB.MENU_DESC - ) + ), + MENU_SUB.MENU_ICON FROM MENU_INFO MENU_SUB JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID WHERE MENU_SUB.OBJID != ANY(V_MENU.PATH) @@ -374,6 +377,7 @@ export class AdminService { COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME, A.TRANSLATED_NAME, A.TRANSLATED_DESC, + A.MENU_ICON, CASE UPPER(A.STATUS) WHEN 'ACTIVE' THEN '활성화' WHEN 'INACTIVE' THEN '비활성화' @@ -514,7 +518,8 @@ export class AdminService { LANG_KEY, LANG_KEY_DESC, PATH, - CYCLE + CYCLE, + MENU_ICON ) AS ( SELECT 1 AS LEVEL, @@ -532,7 +537,8 @@ export class AdminService { LANG_KEY, LANG_KEY_DESC, ARRAY [MENU.OBJID], - FALSE + FALSE, + MENU.MENU_ICON FROM MENU_INFO MENU WHERE PARENT_OBJ_ID = 0 AND MENU_TYPE = 1 @@ -558,7 +564,8 @@ export class AdminService { MENU_SUB.LANG_KEY, MENU_SUB.LANG_KEY_DESC, PATH || MENU_SUB.SEQ::numeric, - MENU_SUB.OBJID = ANY(PATH) + MENU_SUB.OBJID = ANY(PATH), + MENU_SUB.MENU_ICON FROM MENU_INFO MENU_SUB JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID WHERE MENU_SUB.STATUS = 'active' @@ -584,10 +591,9 @@ export class AdminService { A.COMPANY_CODE, A.LANG_KEY, A.LANG_KEY_DESC, + A.MENU_ICON, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME, - -- 번역된 메뉴명 (우선순위: 번역 > 기본명) COALESCE(MLT_NAME.lang_text, A.MENU_NAME_KOR) AS TRANSLATED_NAME, - -- 번역된 설명 (우선순위: 번역 > 기본명) COALESCE(MLT_DESC.lang_text, A.MENU_DESC) AS TRANSLATED_DESC, CASE UPPER(A.STATUS) WHEN 'ACTIVE' THEN '활성화' diff --git a/frontend/components/admin/MenuFormModal.tsx b/frontend/components/admin/MenuFormModal.tsx index 43f17b52..c9940d78 100644 --- a/frontend/components/admin/MenuFormModal.tsx +++ b/frontend/components/admin/MenuFormModal.tsx @@ -20,6 +20,7 @@ import { toast } from "sonner"; import { ChevronDown, Search } from "lucide-react"; import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang"; import { ScreenDefinition } from "@/types/screen"; +import { MenuIconPicker } from "./MenuIconPicker"; interface Company { company_code: string; @@ -77,6 +78,7 @@ export const MenuFormModal: React.FC = ({ status: "ACTIVE", companyCode: parentCompanyCode || "none", langKey: "", + menuIcon: "", }); // 화면 할당 관련 상태 @@ -275,6 +277,7 @@ export const MenuFormModal: React.FC = ({ const status = menu.status || menu.STATUS || "active"; const companyCode = menu.company_code || menu.COMPANY_CODE || ""; const langKey = menu.lang_key || menu.LANG_KEY || ""; + const menuIcon = menu.menu_icon || menu.MENU_ICON || ""; // 메뉴 타입 변환 (admin/user -> 0/1) let convertedMenuType = menuType; @@ -307,7 +310,8 @@ export const MenuFormModal: React.FC = ({ menuType: convertedMenuType, status: convertedStatus, companyCode: companyCode, - langKey: langKey, // 다국어 키 설정 + langKey: langKey, + menuIcon: menuIcon, }); // URL 타입 설정 @@ -420,9 +424,10 @@ export const MenuFormModal: React.FC = ({ menuDesc: "", seq: 1, menuType: defaultMenuType, - status: "ACTIVE", // 기본값은 활성화 - companyCode: parentCompanyCode || "none", // 상위 메뉴의 회사 코드를 기본값으로 설정 - langKey: "", // 다국어 키 초기화 + status: "ACTIVE", + companyCode: parentCompanyCode || "none", + langKey: "", + menuIcon: "", }); // console.log("메뉴 등록 기본값 설정:", { @@ -839,6 +844,11 @@ export const MenuFormModal: React.FC = ({ /> + handleInputChange("menuIcon", iconName)} + /> +
diff --git a/frontend/components/admin/MenuIconPicker.tsx b/frontend/components/admin/MenuIconPicker.tsx new file mode 100644 index 00000000..76919bc8 --- /dev/null +++ b/frontend/components/admin/MenuIconPicker.tsx @@ -0,0 +1,553 @@ +"use client"; + +import React, { useState, useMemo, useRef, useEffect, useCallback } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { Search, X, ChevronDown } from "lucide-react"; +import * as LucideIcons from "lucide-react"; + +type IconComponent = React.FC<{ className?: string }>; + +// lucide-react에서 아이콘 컴포넌트만 필터링 (유틸 함수, 타입 등 제외) +const EXCLUDED_EXPORTS = new Set([ + "createLucideIcon", + "defaultAttributes", + "Icon", + "icons", + "default", +]); + +// PascalCase인지 확인 (아이콘 컴포넌트는 모두 PascalCase) +const isPascalCase = (str: string): boolean => /^[A-Z][a-zA-Z0-9]*$/.test(str); + +// 한글 키워드 매핑 (자주 쓰는 아이콘에 한글 검색어 추가) +const KOREAN_KEYWORDS: Record = { + Home: ["홈", "메인", "대시보드"], + FileText: ["문서", "파일", "텍스트"], + Users: ["사용자", "회원", "인사", "팀"], + User: ["사용자", "회원", "개인"], + Settings: ["설정", "관리", "시스템"], + Shield: ["보안", "권한", "관리자"], + Package: ["제품", "품목", "패키지", "상품"], + BarChart3: ["통계", "차트", "분석", "리포트"], + BarChart2: ["통계", "차트", "분석"], + BarChart: ["통계", "차트"], + Building2: ["회사", "조직", "건물", "부서"], + Building: ["회사", "건물"], + ShoppingCart: ["영업", "판매", "주문", "장바구니"], + ShoppingBag: ["쇼핑", "가방", "구매"], + Truck: ["물류", "배송", "운송", "출하"], + Warehouse: ["창고", "재고", "입고"], + Factory: ["생산", "공장", "제조"], + Wrench: ["설비", "유지보수", "수리", "도구"], + ClipboardCheck: ["품질", "검사", "체크리스트"], + ClipboardList: ["작업지시", "지시서", "할일"], + Clipboard: ["클립보드", "복사"], + DollarSign: ["회계", "금액", "비용", "가격"], + Receipt: ["영수증", "청구", "전표"], + Calendar: ["일정", "캘린더", "날짜"], + CalendarDays: ["일정", "캘린더", "날짜", "일"], + Clock: ["시간", "이력", "히스토리"], + FolderOpen: ["폴더", "분류", "카테고리"], + Folder: ["폴더", "분류", "그룹"], + FolderPlus: ["폴더추가", "분류추가"], + Database: ["데이터", "DB", "저장소"], + Globe: ["글로벌", "다국어", "웹", "세계"], + Mail: ["메일", "이메일"], + Bell: ["알림", "벨", "통지"], + BellRing: ["알림", "벨", "울림"], + Search: ["검색", "조회", "찾기"], + ListOrdered: ["목록", "리스트", "순서"], + List: ["목록", "리스트"], + LayoutGrid: ["그리드", "레이아웃", "화면"], + LayoutDashboard: ["대시보드", "레이아웃"], + Tag: ["태그", "라벨", "분류"], + Tags: ["태그", "라벨", "분류", "복수"], + BookOpen: ["문서", "매뉴얼", "가이드"], + Book: ["책", "문서"], + Boxes: ["BOM", "자재", "부품", "구성"], + Box: ["박스", "상자", "제품"], + GitBranch: ["흐름", "분기", "프로세스"], + Workflow: ["워크플로우", "플로우", "프로세스"], + ArrowRightLeft: ["이동", "전환", "교환"], + ArrowRight: ["오른쪽", "다음", "진행"], + ArrowLeft: ["왼쪽", "이전", "뒤로"], + ArrowUp: ["위", "상승", "업"], + ArrowDown: ["아래", "하강", "다운"], + Layers: ["레이어", "계층", "구조"], + PieChart: ["파이차트", "통계", "비율"], + TrendingUp: ["추세", "성장", "상승"], + TrendingDown: ["추세", "하락", "하강"], + AlertTriangle: ["경고", "주의"], + AlertCircle: ["경고", "주의", "원"], + CheckCircle: ["완료", "승인", "확인"], + CheckCircle2: ["완료", "승인", "확인"], + Check: ["확인", "체크"], + Cog: ["톱니바퀴", "설정", "옵션"], + Map: ["지도", "위치", "경로"], + MapPin: ["지도핀", "위치", "장소"], + Printer: ["프린터", "인쇄", "출력"], + UserCog: ["사용자설정", "계정", "프로필"], + UserPlus: ["사용자추가", "회원가입"], + UserCheck: ["사용자확인", "인증"], + Key: ["키", "권한", "인증", "보안"], + Lock: ["잠금", "보안", "비밀번호"], + LockOpen: ["잠금해제", "열기"], + Unlock: ["잠금해제"], + Hammer: ["작업", "공구", "수리"], + Ruler: ["측정", "규격", "사양"], + Scan: ["스캔", "바코드", "QR"], + QrCode: ["QR코드", "큐알"], + ScrollText: ["계약", "문서", "스크롤"], + HandCoins: ["구매", "발주", "거래"], + CircleDollarSign: ["매출", "수익", "원가"], + FileSpreadsheet: ["엑셀", "스프레드시트", "표"], + FilePlus2: ["신규", "추가", "등록"], + FilePlus: ["파일추가", "신규"], + FileCheck2: ["승인", "결재", "확인"], + FileCheck: ["파일확인"], + Zap: ["전기", "에너지", "빠른"], + Gauge: ["게이지", "성능", "속도"], + HardDrive: ["저장", "서버", "디스크"], + Monitor: ["모니터", "화면", "디스플레이"], + Smartphone: ["모바일", "스마트폰", "앱"], + Lightbulb: ["아이디어", "제안", "개선"], + Star: ["별", "즐겨찾기", "중요"], + Heart: ["좋아요", "관심", "찜"], + Bookmark: ["북마크", "저장", "즐겨찾기"], + Flag: ["플래그", "깃발", "표시"], + Award: ["수상", "인증", "포상"], + Trophy: ["트로피", "우승", "성과"], + Target: ["목표", "타겟", "대상"], + Crosshair: ["크로스헤어", "조준", "정확"], + Eye: ["보기", "조회", "미리보기"], + EyeOff: ["숨기기", "비공개"], + Image: ["이미지", "사진", "그림"], + Camera: ["카메라", "사진", "촬영"], + Video: ["비디오", "영상", "동영상"], + Music: ["음악", "오디오", "사운드"], + Mic: ["마이크", "음성", "녹음"], + Phone: ["전화", "연락", "콜"], + PhoneCall: ["통화", "전화"], + MessageSquare: ["메시지", "채팅", "대화"], + MessageCircle: ["메시지", "채팅"], + Send: ["보내기", "전송", "발송"], + Share2: ["공유", "전달"], + Link: ["링크", "연결", "URL"], + ExternalLink: ["외부링크", "새창"], + Download: ["다운로드", "내려받기"], + Upload: ["업로드", "올리기"], + CloudUpload: ["클라우드업로드", "올리기"], + CloudDownload: ["클라우드다운로드", "내려받기"], + Cloud: ["클라우드", "구름"], + Server: ["서버", "시스템"], + Cpu: ["CPU", "프로세서", "처리"], + Wifi: ["와이파이", "네트워크", "무선"], + Activity: ["활동", "모니터링", "심박"], + Thermometer: ["온도", "온도계", "측정"], + Droplets: ["물", "수질", "액체"], + Wind: ["바람", "공기", "환기"], + Sun: ["태양", "밝기", "낮"], + Moon: ["달", "야간", "다크모드"], + Umbrella: ["우산", "보호", "보험"], + Compass: ["나침반", "방향", "가이드"], + Navigation: ["네비게이션", "안내"], + RotateCcw: ["되돌리기", "새로고침", "초기화"], + RefreshCw: ["새로고침", "갱신", "동기화"], + Repeat: ["반복", "되풀이"], + Shuffle: ["셔플", "무작위", "랜덤"], + Filter: ["필터", "거르기", "조건"], + SlidersHorizontal: ["슬라이더", "조정", "필터"], + Maximize2: ["최대화", "전체화면"], + Minimize2: ["최소화", "축소"], + Move: ["이동", "옮기기"], + Copy: ["복사", "복제"], + Scissors: ["가위", "잘라내기"], + Trash2: ["삭제", "쓰레기통", "휴지통"], + Trash: ["삭제", "쓰레기"], + Archive: ["보관", "아카이브", "저장"], + ArchiveRestore: ["복원", "복구"], + Plus: ["추가", "더하기", "플러스"], + Minus: ["빼기", "마이너스", "제거"], + PlusCircle: ["추가", "원형추가"], + MinusCircle: ["제거", "원형제거"], + XCircle: ["닫기", "취소", "제거"], + Info: ["정보", "안내", "도움말"], + HelpCircle: ["도움말", "질문", "안내"], + CircleAlert: ["경고", "주의", "원형경고"], + Ban: ["금지", "차단", "비허용"], + ShieldCheck: ["보안확인", "인증완료"], + ShieldAlert: ["보안경고", "위험"], + LogIn: ["로그인", "접속"], + LogOut: ["로그아웃", "종료"], + Power: ["전원", "켜기/끄기"], + ToggleLeft: ["토글", "스위치", "끄기"], + ToggleRight: ["토글", "스위치", "켜기"], + Percent: ["퍼센트", "비율", "할인"], + Hash: ["해시", "번호", "코드"], + AtSign: ["앳", "이메일", "골뱅이"], + Code: ["코드", "개발", "프로그래밍"], + Terminal: ["터미널", "명령어", "콘솔"], + Table: ["테이블", "표", "데이터"], + Table2: ["테이블", "표"], + Columns: ["컬럼", "열", "항목"], + Rows: ["행", "줄"], + Grid3x3: ["그리드", "격자", "표"], + PanelLeft: ["패널", "사이드바", "왼쪽"], + PanelRight: ["패널", "사이드바", "오른쪽"], + Split: ["분할", "나누기"], + Combine: ["결합", "합치기"], + Network: ["네트워크", "연결망"], + Radio: ["라디오", "옵션"], + CircleDot: ["원형점", "선택"], + SquareCheck: ["체크박스", "선택"], + Square: ["사각형", "상자"], + Circle: ["원", "동그라미"], + Triangle: ["삼각형", "세모"], + Hexagon: ["육각형", "벌집"], + Diamond: ["다이아몬드", "마름모"], + Pen: ["펜", "작성", "편집"], + Pencil: ["연필", "수정", "편집"], + PenLine: ["펜라인", "서명"], + Eraser: ["지우개", "삭제", "초기화"], + Palette: ["팔레트", "색상", "디자인"], + Paintbrush: ["브러시", "페인트", "디자인"], + Figma: ["피그마", "디자인"], + Type: ["타입", "글꼴", "폰트"], + Bold: ["굵게", "볼드"], + Italic: ["기울임", "이탤릭"], + AlignLeft: ["왼쪽정렬"], + AlignCenter: ["가운데정렬"], + AlignRight: ["오른쪽정렬"], + Footprints: ["발자국", "추적", "이력"], + Fingerprint: ["지문", "인증", "보안"], + ScanLine: ["스캔라인", "인식"], + Barcode: ["바코드"], + CreditCard: ["신용카드", "결제", "카드"], + Wallet: ["지갑", "결제", "자금"], + Banknote: ["지폐", "현금", "돈"], + Coins: ["동전", "코인"], + PiggyBank: ["저금통", "저축", "예산"], + Landmark: ["랜드마크", "은행", "기관"], + Store: ["매장", "상점", "가게"], + GraduationCap: ["졸업", "교육", "학습"], + School: ["학교", "교육", "훈련"], + Library: ["도서관", "라이브러리"], + BookMarked: ["북마크", "표시된책"], + Notebook: ["노트북", "공책", "메모"], + NotebookPen: ["노트작성", "메모"], + FileArchive: ["압축파일", "아카이브"], + FileAudio: ["오디오파일", "음악파일"], + FileVideo: ["비디오파일", "영상파일"], + FileImage: ["이미지파일", "사진파일"], + FileCode: ["코드파일", "소스파일"], + FileJson: ["JSON파일", "데이터파일"], + FileCog: ["파일설정", "환경설정"], + FileSearch: ["파일검색", "문서검색"], + FileWarning: ["파일경고", "주의파일"], + FileX: ["파일삭제", "파일제거"], + Files: ["파일들", "다중파일"], + FolderSearch: ["폴더검색"], + FolderCog: ["폴더설정"], + FolderInput: ["입력폴더", "수신"], + FolderOutput: ["출력폴더", "발신"], + FolderSync: ["폴더동기화"], + FolderTree: ["폴더트리", "계층구조"], + Inbox: ["받은편지함", "수신"], + MailOpen: ["메일열기", "읽음"], + MailPlus: ["메일추가", "새메일"], + CalendarCheck: ["일정확인", "예약확인"], + CalendarPlus: ["일정추가", "새일정"], + CalendarX: ["일정취소", "일정삭제"], + Timer: ["타이머", "시간측정"], + Hourglass: ["모래시계", "대기", "로딩"], + AlarmClock: ["알람", "시계"], + Watch: ["시계", "손목시계"], + Rocket: ["로켓", "출시", "배포"], + Plane: ["비행기", "항공", "운송"], + Ship: ["배", "선박", "해운"], + Car: ["자동차", "차량"], + Bus: ["버스", "대중교통"], + Train: ["기차", "열차", "철도"], + Bike: ["자전거", "이동"], + Fuel: ["연료", "주유"], + Construction: ["공사", "건설", "설치"], + HardHat: ["안전모", "건설", "안전"], + Shovel: ["삽", "건설", "시공"], + Drill: ["드릴", "공구"], + Nut: ["너트", "부품", "볼트"], + Plug: ["플러그", "전원", "연결"], + Cable: ["케이블", "선", "연결"], + Battery: ["배터리", "충전"], + BatteryCharging: ["충전중", "배터리"], + Signal: ["신호", "강도"], + Antenna: ["안테나", "수신"], + Bluetooth: ["블루투스", "무선"], + Usb: ["USB", "연결"], + SquareStack: ["스택", "쌓기", "레이어"], + Component: ["컴포넌트", "부품", "구성요소"], + Puzzle: ["퍼즐", "조각", "모듈"], + Blocks: ["블록", "구성요소"], + GitCommit: ["커밋", "변경"], + GitMerge: ["병합", "머지"], + GitPullRequest: ["풀리퀘스트", "요청"], + GitCompare: ["비교", "차이"], + CirclePlay: ["재생", "플레이"], + CirclePause: ["일시정지", "멈춤"], + CircleStop: ["정지", "중지"], + SkipForward: ["다음", "건너뛰기"], + SkipBack: ["이전", "뒤로"], + Volume2: ["볼륨", "소리"], + VolumeX: ["음소거"], + Headphones: ["헤드폰", "오디오"], + Speaker: ["스피커", "소리"], + Projector: ["프로젝터", "발표"], + Presentation: ["프레젠테이션", "발표"], + GanttChart: ["간트차트", "일정관리", "프로젝트"], + KanbanSquare: ["칸반", "보드", "프로젝트"], + ListTodo: ["할일목록", "체크리스트"], + ListChecks: ["체크목록", "확인목록"], + ListFilter: ["필터목록", "조건목록"], + ListTree: ["트리목록", "계층목록"], + StretchHorizontal: ["가로확장"], + StretchVertical: ["세로확장"], + Maximize: ["최대화"], + Minimize: ["최소화"], + Expand: ["확장", "펼치기"], + Shrink: ["축소", "줄이기"], + ZoomIn: ["확대"], + ZoomOut: ["축소"], + Focus: ["포커스", "집중"], + Crosshairs: ["조준", "대상"], + Locate: ["위치찾기", "현재위치"], + LocateFixed: ["위치고정"], + LocateOff: ["위치끄기"], + Spline: ["스플라인", "곡선"], + BrainCircuit: ["AI", "인공지능", "두뇌"], + Brain: ["두뇌", "지능", "생각"], + Bot: ["봇", "로봇", "자동화"], + Sparkles: ["반짝", "AI", "마법"], + Wand2: ["마법봉", "자동", "AI"], + FlaskConical: ["실험", "연구", "시험"], + TestTube: ["시험관", "검사", "테스트"], + Microscope: ["현미경", "분석", "연구"], + Stethoscope: ["청진기", "의료", "진단"], + Syringe: ["주사기", "의료"], + Pill: ["약", "의약품"], + HeartPulse: ["심박", "건강", "의료"], + Dna: ["DNA", "유전", "생명과학"], + Atom: ["원자", "과학", "화학"], + Beaker: ["비커", "실험", "화학"], + Scale: ["저울", "무게", "측정"], + Weight: ["무게", "중량"], + Ratio: ["비율", "비교"], + Calculator: ["계산기", "계산"], + Binary: ["이진수", "코드"], + Regex: ["정규식", "패턴"], + Variable: ["변수", "값"], + FunctionSquare: ["함수", "기능"], + Braces: ["중괄호", "코드"], + Brackets: ["대괄호", "배열"], + Parentheses: ["소괄호", "그룹"], + Tally5: ["집계", "카운트", "합계"], + Sigma: ["시그마", "합계", "총합"], + Infinity: ["무한", "반복"], + Pi: ["파이", "수학"], + Omega: ["오메가", "마지막"], +}; + +interface IconEntry { + name: string; + component: IconComponent; + keywords: string[]; +} + +// 모든 Lucide 아이콘을 동적으로 가져오기 +const ALL_ICONS: IconEntry[] = (() => { + const entries: IconEntry[] = []; + for (const [name, maybeComponent] of Object.entries(LucideIcons)) { + if (EXCLUDED_EXPORTS.has(name)) continue; + if (!isPascalCase(name)) continue; + // lucide-react 아이콘은 forwardRef + memo로 감싸진 React 컴포넌트 (object) + const comp = maybeComponent as any; + const isReactComponent = + typeof comp === "function" || + (typeof comp === "object" && comp !== null && comp.$$typeof); + if (!isReactComponent) continue; + + const koreanKw = KOREAN_KEYWORDS[name] || []; + entries.push({ + name, + component: comp as IconComponent, + keywords: [...koreanKw, name.toLowerCase()], + }); + } + return entries.sort((a, b) => a.name.localeCompare(b.name)); +})(); + +export function getIconComponent(iconName: string | null | undefined): IconComponent | null { + if (!iconName) return null; + const entry = ALL_ICONS.find((e) => e.name === iconName); + return entry?.component || null; +} + +interface MenuIconPickerProps { + value: string; + onChange: (iconName: string) => void; + label?: string; +} + +export const MenuIconPicker: React.FC = ({ + value, + onChange, + label = "메뉴 아이콘", +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchText, setSearchText] = useState(""); + const [visibleCount, setVisibleCount] = useState(120); + const containerRef = useRef(null); + const scrollRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + setSearchText(""); + } + }; + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen]); + + // 드롭다운 열릴 때 표시 개수 초기화 + useEffect(() => { + if (isOpen) setVisibleCount(120); + }, [isOpen]); + + // 검색어 변경 시 표시 개수 초기화 + useEffect(() => { + setVisibleCount(120); + }, [searchText]); + + const filteredIcons = useMemo(() => { + if (!searchText) return ALL_ICONS; + const lower = searchText.toLowerCase(); + return ALL_ICONS.filter( + (entry) => + entry.name.toLowerCase().includes(lower) || + entry.keywords.some((kw) => kw.includes(lower)) + ); + }, [searchText]); + + // 스크롤 끝에 도달하면 더 로드 + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) { + setVisibleCount((prev) => Math.min(prev + 120, filteredIcons.length)); + } + }, [filteredIcons.length]); + + const selectedIcon = ALL_ICONS.find((e) => e.name === value); + const SelectedIconComponent = selectedIcon?.component; + + return ( +
+ +
+ + + {isOpen && ( +
+
+
+ + setSearchText(e.target.value)} + className="h-8 pl-8 text-sm" + onClick={(e) => e.stopPropagation()} + autoFocus + /> +
+
+ +
+ {!searchText && ( +

+ 총 {ALL_ICONS.length}개 아이콘 +

+ )} +
+ {filteredIcons.slice(0, visibleCount).map((entry) => { + const IconComp = entry.component; + return ( + + ); + })} +
+ {filteredIcons.length === 0 && ( +

+ 검색 결과가 없습니다. +

+ )} +
+
+ )} +
+
+ ); +}; diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index de2c5b61..5223bc39 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -45,6 +45,7 @@ import { DialogDescription, } from "@/components/ui/dialog"; import { CompanySwitcher } from "@/components/admin/CompanySwitcher"; +import { getIconComponent } from "@/components/admin/MenuIconPicker"; // useAuth의 UserInfo 타입을 확장 interface ExtendedUserInfo { @@ -74,8 +75,13 @@ interface AppLayoutProps { children: React.ReactNode; } -// 메뉴 아이콘 매핑 함수 -const getMenuIcon = (menuName: string) => { +// 메뉴 아이콘 매핑 함수 (DB 아이콘 우선, 없으면 키워드 기반 fallback) +const getMenuIcon = (menuName: string, dbIconName?: string | null) => { + if (dbIconName) { + const DbIcon = getIconComponent(dbIconName); + if (DbIcon) return ; + } + const name = menuName.toLowerCase(); if (name.includes("대시보드") || name.includes("dashboard")) return ; if (name.includes("관리자") || name.includes("admin")) return ; @@ -205,7 +211,7 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten return { id: menuId, name: getDisplayText(menu), - icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || ""), + icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON), url: menu.menu_url || menu.MENU_URL || "#", children: children.length > 0 ? children : undefined, hasChildren: children.length > 0, diff --git a/frontend/components/layout/MainSidebar.tsx b/frontend/components/layout/MainSidebar.tsx index 97721831..e544c750 100644 --- a/frontend/components/layout/MainSidebar.tsx +++ b/frontend/components/layout/MainSidebar.tsx @@ -2,6 +2,7 @@ import { ChevronDown, ChevronRight, Home, FileText, Users, BarChart3, Cog, GitBr import { cn } from "@/lib/utils"; import { MenuItem } from "@/types/menu"; import { MENU_ICONS, MESSAGES } from "@/constants/layout"; +import { getIconComponent } from "@/components/admin/MenuIconPicker"; interface MainSidebarProps { menuList: MenuItem[]; @@ -11,9 +12,14 @@ interface MainSidebarProps { } /** - * 메뉴 아이콘 선택 함수 + * 메뉴 아이콘 선택 함수 (DB 아이콘 우선, 없으면 키워드 기반 fallback) */ -const getMenuIcon = (menuName: string) => { +const getMenuIcon = (menuName: string, dbIconName?: string | null) => { + if (dbIconName) { + const DbIcon = getIconComponent(dbIconName); + if (DbIcon) return ; + } + if (MENU_ICONS.HOME.some((keyword) => menuName.includes(keyword))) { return ; } @@ -57,7 +63,7 @@ export function MainSidebar({ menuList, expandedMenus, onMenuClick, className = )} >
- {getMenuIcon(menu.MENU_NAME_KOR || menu.menuNameKor || "")} + {getMenuIcon(menu.MENU_NAME_KOR || menu.menuNameKor || "", menu.MENU_ICON || menu.menu_icon)} {menu.MENU_NAME_KOR || menu.menuNameKor || "메뉴"}
{hasChildren && (isExpanded ? : )} diff --git a/frontend/contexts/MenuContext.tsx b/frontend/contexts/MenuContext.tsx index 88b15542..30661de2 100644 --- a/frontend/contexts/MenuContext.tsx +++ b/frontend/contexts/MenuContext.tsx @@ -38,7 +38,9 @@ export function MenuProvider({ children }: { children: ReactNode }) { regdate: item.REGDATE || item.regdate, company_code: item.COMPANY_CODE || item.company_code, company_name: item.COMPANY_NAME || item.company_name, - // 다국어 관련 필드 추가 + // 아이콘 필드 + menu_icon: item.MENU_ICON || item.menu_icon, + // 다국어 관련 필드 lang_key: item.LANG_KEY || item.lang_key, lang_key_desc: item.LANG_KEY_DESC || item.lang_key_desc, translated_name: item.TRANSLATED_NAME || item.translated_name, diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 67de76ae..8611aeda 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -40,6 +40,8 @@ export interface MenuItem { TRANSLATED_NAME?: string; translated_desc?: string; TRANSLATED_DESC?: string; + menu_icon?: string; + MENU_ICON?: string; } export interface MenuFormData { @@ -52,8 +54,9 @@ export interface MenuFormData { menuType: string; status: string; companyCode: string; - langKey?: string; // 다국어 키 추가 - screenCode?: string; // 화면 코드 추가 + langKey?: string; + screenCode?: string; + menuIcon?: string; } export interface LangKey { diff --git a/frontend/types/menu.ts b/frontend/types/menu.ts index f1186124..763bc16f 100644 --- a/frontend/types/menu.ts +++ b/frontend/types/menu.ts @@ -23,6 +23,9 @@ export interface MenuItem { // 계층적 메뉴 구조를 위한 필드들 children?: MenuItem[]; + // 아이콘 필드 + menu_icon?: string; + // 번역 관련 필드들 translated_name?: string; translated_desc?: string; @@ -47,6 +50,9 @@ export interface MenuItem { COMPANY_CODE?: string; COMPANY_NAME?: string; + // 아이콘 대문자 키 + MENU_ICON?: string; + // 번역 관련 대문자 키들 TRANSLATED_NAME?: string; TRANSLATED_DESC?: string;