jskim-node #398
|
|
@ -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),
|
||||
]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 '활성화'
|
||||
|
|
|
|||
|
|
@ -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<MenuFormModalProps> = ({
|
|||
status: "ACTIVE",
|
||||
companyCode: parentCompanyCode || "none",
|
||||
langKey: "",
|
||||
menuIcon: "",
|
||||
});
|
||||
|
||||
// 화면 할당 관련 상태
|
||||
|
|
@ -275,6 +277,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
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<MenuFormModalProps> = ({
|
|||
menuType: convertedMenuType,
|
||||
status: convertedStatus,
|
||||
companyCode: companyCode,
|
||||
langKey: langKey, // 다국어 키 설정
|
||||
langKey: langKey,
|
||||
menuIcon: menuIcon,
|
||||
});
|
||||
|
||||
// URL 타입 설정
|
||||
|
|
@ -420,9 +424,10 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
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<MenuFormModalProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<MenuIconPicker
|
||||
value={formData.menuIcon || ""}
|
||||
onChange={(iconName) => handleInputChange("menuIcon", iconName)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string[]> = {
|
||||
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<MenuIconPickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
label = "메뉴 아이콘",
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [visibleCount, setVisibleCount] = useState(120);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="space-y-2">
|
||||
<Label>{label}</Label>
|
||||
<div className="relative" ref={containerRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="h-10 w-full justify-between text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{SelectedIconComponent ? (
|
||||
<>
|
||||
<SelectedIconComponent className="h-4 w-4" />
|
||||
<span>{selectedIcon?.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">아이콘을 선택하세요 (선택사항)</span>
|
||||
)}
|
||||
</div>
|
||||
{value ? (
|
||||
<X
|
||||
className="h-4 w-4 opacity-50 hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange("");
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-lg">
|
||||
<div className="border-b p-2">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="아이콘 검색 (한글/영문)..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-8 pl-8 text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="max-h-72 overflow-y-auto p-2"
|
||||
>
|
||||
{!searchText && (
|
||||
<p className="text-muted-foreground mb-2 text-center text-xs">
|
||||
총 {ALL_ICONS.length}개 아이콘
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
{filteredIcons.slice(0, visibleCount).map((entry) => {
|
||||
const IconComp = entry.component;
|
||||
return (
|
||||
<button
|
||||
key={entry.name}
|
||||
type="button"
|
||||
title={entry.name}
|
||||
onClick={() => {
|
||||
onChange(entry.name);
|
||||
setIsOpen(false);
|
||||
setSearchText("");
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-md p-2 transition-colors hover:bg-accent",
|
||||
value === entry.name && "bg-primary/10 ring-primary ring-1"
|
||||
)}
|
||||
>
|
||||
<IconComp className="h-5 w-5" />
|
||||
<span className="mt-1 max-w-full truncate text-[9px] leading-tight">{entry.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filteredIcons.length === 0 && (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
검색 결과가 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 <DbIcon className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
const name = menuName.toLowerCase();
|
||||
if (name.includes("대시보드") || name.includes("dashboard")) return <Home className="h-4 w-4" />;
|
||||
if (name.includes("관리자") || name.includes("admin")) return <Shield className="h-4 w-4" />;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <DbIcon className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
if (MENU_ICONS.HOME.some((keyword) => menuName.includes(keyword))) {
|
||||
return <Home className="h-4 w-4" />;
|
||||
}
|
||||
|
|
@ -57,7 +63,7 @@ export function MainSidebar({ menuList, expandedMenus, onMenuClick, className =
|
|||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{getMenuIcon(menu.MENU_NAME_KOR || menu.menuNameKor || "")}
|
||||
{getMenuIcon(menu.MENU_NAME_KOR || menu.menuNameKor || "", menu.MENU_ICON || menu.menu_icon)}
|
||||
<span>{menu.MENU_NAME_KOR || menu.menuNameKor || "메뉴"}</span>
|
||||
</div>
|
||||
{hasChildren && (isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue