Files
invyone/frontend/lib/registry/components/common/IconPicker.tsx
T
DDD1542 3883031c0b feat(studio): Phase G — KPI stats / chart / cardList / groupedTable + canonical container tabs
INV Studio 데이터 뷰 시리즈. 솔루션 개발 단계라 backward-compat alias 없이 깔끔하게.

Backend:
- TableManagementController + Service: /aggregate, /aggregate-group, /select-rows endpoint 추가
  sanitize + hasColumn 검증 + buildAggregateWhere 공유 헬퍼

Frontend canonical view components (신규):
- stats: DB-first KPI editor (CPSegment 메타 chip, 컬럼 dropdown, 디자인 모드 debounce 350ms preview)
- chart: recharts (bar / horizontalBar / line / donut)
- card-list: title/subtitles/metrics 카드 카탈로그 (list / grid 레이아웃)
- grouped-table: 클라이언트 측 groupBy + 그룹 헤더 row

Canonical container (Phase G.2 / G.2.5 / G.2.6):
- containerType='tabs' 활성 탭만 mount, ChildSlot 으로 자식 렌더
- ScreenDesigner.handleComponentDrop 가 canonical container tabs 도 인식
- 우측 V2PropertiesPanel 4-way 분기: tab child / panel child / selected / empty
  nested path update + saveToHistory, delete handler 동기화

Shared utilities:
- useDbColumns hook (모듈 캐시), ColumnPicker (CPSelect 기반)
- OptionFilterRow 자연어 카드 형식 (컬럼 dropdown / 조건 select / 값 입력)
- _shared/use-table-rows.ts (cardList + groupedTable 공용 fetch)
- IconPicker: 한글 키워드 80+ alias, 휠 스크롤 fix, 360px 상한, 결과 80→300

stats DB-first UX (Phase G.4.x):
- DB / 정적 모드 이분법 제거 — 항상 dataSource 시작
- collapsed: 라벨 input + KpiMetaSegment chip (테이블 · 집계 · 컬럼 · 필터수)
- expanded: 데이터 / 필터 / 외형 / 고급 flat CP rows
- useSlideToggle hook 으로 펼침/닫힘 양방향 애니메이션
- 변화량 (delta) 수동 입력 UI 제거 — 향후 DB 자동 계산 영역
- 카드 fetch state 명시: loading / error / 대기 중 / 테이블 미설정

기타:
- ScreenDesigner.tsx → InvyoneStudio.tsx rename (활성 빌더 파일)
- 모든 hardcoded #6c5ce7 fallback 제거, hsl(var(--primary)) 토큰만 사용 (light/dark/테마 자동 적응)
- StatsDefinition default_config 도 DB-first placeholder (value: 0 박지 않음)

Docs:
- notes/gbpark/2026-05-14-studio-data-view-roadmap.md (G.0 ~ G.4.2 진행 기록)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:41:50 +09:00

576 lines
19 KiB
TypeScript

"use client";
/**
* IconPicker — cp 톤 아이콘 picker
*
* Codex 검토 (2026-04-29 Y4) 권고: stats/button 두 cp 패널 공용 UI 일관성 확보.
* 외부 사용처 (layout/admin/dash) 는 별개 `MenuIconPicker` 사용 → 영향 없음.
*
* 시그니처 / 동작 (외부 클릭 닫기 없음 등) 은 이전과 동일. 시각 톤만 cp 변수 + 28px 표준 사이즈로 통일.
*/
import React, { useState, useMemo, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import * as LucideIcons from "lucide-react";
// lucide-react에서 실제 아이콘만 추출 (유틸/타입 제외)
const ICON_ENTRIES = Object.entries(LucideIcons).filter(
([name, comp]) =>
typeof comp === "object" &&
name !== "default" &&
name !== "createLucideIcon" &&
name !== "icons" &&
name[0] === name[0].toUpperCase() &&
!name.endsWith("Icon"),
);
// 자주 쓰는 아이콘 (상단 표시)
const POPULAR = [
"Search", "Plus", "Edit", "Trash2", "Save", "Check", "X", "ChevronDown",
"ChevronRight", "Settings", "User", "Users", "Mail", "Calendar", "Clock",
"File", "Folder", "Download", "Upload", "Eye", "EyeOff", "Lock", "Unlock",
"Star", "Heart", "Home", "ArrowLeft", "ArrowRight", "RefreshCw", "Filter",
"BarChart3", "PieChart", "TrendingUp", "DollarSign", "ShoppingCart", "Package",
];
/**
* 한글 키워드 → 아이콘 매핑 (검색 시 alias 로 사용).
* lucide 영문 이름과 함께 매치. 자주 쓰일만한 도메인 키워드 위주.
*/
const ICON_KO: Record<string, string[]> = {
// 사용자 / 사원
User: ["사용자", "유저", "사람", "회원", "사원"],
Users: ["사용자", "유저", "사원", "회원", "그룹", "팀", "사람들"],
UserPlus: ["사용자추가", "사원추가", "회원가입", "등록"],
UserMinus: ["사용자삭제", "사원삭제", "탈퇴"],
UserCheck: ["사용자승인", "사원승인", "인증"],
UserX: ["사용자거부", "사원거부", "차단"],
UserCog: ["사용자설정", "권한"],
Contact: ["연락처", "주소록"],
Briefcase: ["가방", "업무", "직장", "프로젝트", "근무"],
Building: ["건물", "회사", "조직"],
Building2: ["빌딩", "회사", "지점", "사업장"],
// 액션
Plus: ["추가", "더하기", "생성", "신규"],
Minus: ["빼기", "제거", "감소"],
Edit: ["편집", "수정", "변경"],
Edit2: ["편집", "수정", "변경"],
Edit3: ["편집", "수정"],
Pencil: ["연필", "수정", "편집", "쓰기"],
Trash: ["휴지통", "삭제", "버리기"],
Trash2: ["휴지통", "삭제", "제거"],
Save: ["저장", "보관"],
Check: ["체크", "확인", "완료", "승인"],
CheckCircle: ["완료", "성공", "확인"],
CheckCircle2: ["완료", "성공"],
X: ["닫기", "취소", "엑스"],
XCircle: ["닫기", "실패", "취소"],
Copy: ["복사"],
Clipboard: ["클립보드", "복사", "붙여넣기"],
Send: ["전송", "보내기", "발송"],
Printer: ["인쇄", "프린트"],
Share: ["공유", "공유하기"],
Share2: ["공유"],
Link: ["링크", "연결"],
Link2: ["링크", "연결"],
Unlink: ["연결끊기"],
Power: ["전원", "켜기", "끄기"],
LogIn: ["로그인", "입장"],
LogOut: ["로그아웃", "나가기", "퇴장"],
// 검색 / 필터
Search: ["검색", "찾기", "조회", "탐색"],
Filter: ["필터", "거르기", "조건"],
SlidersHorizontal: ["조정", "필터", "설정"],
ListFilter: ["필터", "정렬"],
// 화살표 / 이동
ArrowLeft: ["왼쪽", "뒤로", "이전"],
ArrowRight: ["오른쪽", "다음", "앞으로"],
ArrowUp: ["위", "상승"],
ArrowDown: ["아래", "하강"],
ArrowUpDown: ["정렬", "위아래"],
ChevronLeft: ["왼쪽", "이전"],
ChevronRight: ["오른쪽", "다음"],
ChevronUp: ["위"],
ChevronDown: ["아래", "펼침", "드롭다운"],
ChevronsLeft: ["맨앞", "처음"],
ChevronsRight: ["맨뒤", "마지막"],
ChevronsUpDown: ["펼침접힘"],
MoveLeft: ["이동", "왼쪽"],
MoveRight: ["이동", "오른쪽"],
// 시간
Calendar: ["달력", "캘린더", "날짜", "일정"],
CalendarDays: ["달력", "일정"],
CalendarCheck: ["일정확인", "예약"],
CalendarClock: ["일정", "예약시간"],
Clock: ["시계", "시간"],
Clock3: ["시간"],
Clock4: ["시간"],
Timer: ["타이머", "시간측정"],
AlarmClock: ["알람", "알람시계"],
Hourglass: ["모래시계", "대기"],
// 파일 / 폴더 / 문서
File: ["파일", "문서"],
FileText: ["문서", "텍스트"],
FileSpreadsheet: ["스프레드시트", "엑셀"],
FileImage: ["이미지파일"],
FileVideo: ["동영상파일"],
FilePlus: ["파일추가"],
FileMinus: ["파일삭제"],
FileX: ["파일닫기"],
FileCheck: ["파일확인"],
Files: ["파일들", "여러파일"],
Folder: ["폴더", "디렉토리"],
FolderOpen: ["열린폴더"],
FolderPlus: ["폴더추가"],
FolderMinus: ["폴더삭제"],
Archive: ["아카이브", "보관", "압축"],
// 다운로드 / 업로드
Download: ["다운로드", "내려받기"],
Upload: ["업로드", "올리기"],
CloudDownload: ["클라우드다운로드"],
CloudUpload: ["클라우드업로드"],
CloudOff: ["오프라인"],
Cloud: ["클라우드"],
// 시각
Eye: ["눈", "보기", "표시", "조회"],
EyeOff: ["숨기기", "안보기"],
// 보안
Lock: ["잠금", "자물쇠", "보안"],
Unlock: ["잠금해제"],
Shield: ["방패", "보안", "보호"],
ShieldCheck: ["보안인증"],
Key: ["열쇠", "키", "비밀번호"],
KeyRound: ["키"],
// 통신
Mail: ["메일", "이메일", "편지"],
MailOpen: ["메일열기"],
MailPlus: ["메일작성"],
MessageSquare: ["메시지", "댓글"],
MessageCircle: ["메시지"],
Phone: ["전화", "폰"],
PhoneCall: ["통화"],
Smartphone: ["스마트폰", "휴대폰"],
Bell: ["알림", "벨", "종"],
BellOff: ["알림끄기"],
// 위치
MapPin: ["위치", "지도", "핀"],
Map: ["지도"],
Navigation: ["네비게이션", "방향"],
Home: ["홈", "집", "메인"],
Building3: ["건물"],
Compass: ["나침반"],
Globe: ["지구", "글로벌", "전세계"],
// 상태 / 좋아요
Star: ["별", "즐겨찾기", "별점"],
Heart: ["하트", "좋아요", "즐겨찾기"],
ThumbsUp: ["좋아요", "추천"],
ThumbsDown: ["싫어요"],
Bookmark: ["북마크", "즐겨찾기"],
Award: ["수상", "메달", "상"],
Trophy: ["트로피", "우승"],
Flag: ["깃발", "신고", "표시"],
// 화폐 / 결제
DollarSign: ["달러", "돈", "금액", "매출"],
CircleDollarSign: ["달러"],
Banknote: ["지폐", "돈", "현금"],
Wallet: ["지갑"],
CreditCard: ["카드", "신용카드", "결제"],
Receipt: ["영수증"],
Coins: ["동전", "돈"],
Percent: ["퍼센트", "비율", "할인"],
// 카트 / 쇼핑
ShoppingCart: ["카트", "장바구니", "쇼핑"],
ShoppingBag: ["쇼핑백", "주문"],
Store: ["상점", "매장"],
Tag: ["태그", "라벨", "꼬리표"],
Tags: ["태그", "라벨"],
// 박스 / 패키지
Package: ["패키지", "상자", "포장", "재고"],
PackageOpen: ["박스열기", "배송", "포장"],
PackagePlus: ["박스추가", "입고"],
PackageMinus: ["박스삭제", "출고"],
Box: ["박스", "상자"],
Boxes: ["박스", "재고"],
Truck: ["트럭", "배송"],
Container: ["컨테이너", "보관"],
Warehouse: ["창고", "재고"],
// 차트 / 통계
BarChart: ["막대그래프", "차트", "통계"],
BarChart2: ["막대차트"],
BarChart3: ["막대그래프", "차트", "통계", "KPI"],
BarChart4: ["막대차트"],
PieChart: ["파이차트", "비율", "원그래프"],
LineChart: ["선그래프", "추세"],
AreaChart: ["영역차트"],
TrendingUp: ["상승", "증가", "추세"],
TrendingDown: ["하강", "감소"],
Activity: ["활동", "추적"],
Gauge: ["게이지", "측정"],
// 설정 / 도구
Settings: ["설정", "환경설정", "옵션"],
Settings2: ["설정"],
Cog: ["설정", "톱니"],
Sliders: ["조정", "슬라이더"],
Wrench: ["렌치", "도구", "수리"],
Hammer: ["망치", "도구"],
// 새로고침
RefreshCw: ["새로고침", "재로드"],
RefreshCcw: ["새로고침"],
RotateCw: ["회전"],
RotateCcw: ["회전"],
// 정보 / 경고
Info: ["정보", "안내"],
AlertCircle: ["경고", "주의"],
AlertTriangle: ["경고", "위험"],
AlertOctagon: ["위험", "중지"],
HelpCircle: ["도움말", "물음표"],
CircleAlert: ["경고"],
// 메뉴
Menu: ["메뉴", "햄버거"],
MoreHorizontal: ["더보기", "추가메뉴"],
MoreVertical: ["더보기"],
List: ["목록", "리스트"],
ListChecks: ["체크리스트"],
Grid: ["그리드", "격자"],
Grid3x3: ["그리드"],
LayoutGrid: ["그리드", "레이아웃"],
// 데이터 / DB
Database: ["데이터베이스", "DB", "저장소"],
Server: ["서버"],
HardDrive: ["하드드라이브", "저장소"],
Cpu: ["CPU", "프로세서"],
Cable: ["케이블", "연결"],
// 일정 / 작업
ClipboardList: ["체크리스트", "할일"],
ClipboardCheck: ["완료", "확인"],
CheckSquare: ["체크박스", "완료"],
Square: ["사각형", "박스"],
// 기타 자주 쓰이는
Hash: ["해시", "번호", "샵"],
AtSign: ["골뱅이", "이메일", "아이디"],
Bookmark2: ["북마크"],
Pin: ["핀", "고정"],
PinOff: ["고정해제"],
Sun: ["해", "라이트모드"],
Moon: ["달", "다크모드"],
Zap: ["번개", "빠른"],
Sparkles: ["반짝", "특수효과"],
Gift: ["선물"],
Crown: ["왕관", "VIP"],
Bot: ["로봇", "봇", "자동화"],
Code: ["코드", "개발"],
Terminal: ["터미널", "콘솔"],
Bug: ["버그", "벌레", "오류"],
ListTodo: ["할일", "투두"],
GitBranch: ["분기", "브랜치"],
Workflow: ["워크플로우", "흐름"],
Network: ["네트워크"],
};
interface IconPickerProps {
value?: string;
onChange: (iconName: string) => void;
className?: string;
}
export const IconPicker: React.FC<IconPickerProps> = ({ value, onChange, className }) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [focused, setFocused] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [popoverPos, setPopoverPos] = useState<{
top: number;
left: number;
width: number;
maxHeight: number;
} | null>(null);
// 트리거 위치 잡고 popover 열기 (Portal 사용 — 부모 overflow:hidden 영향 회피)
const handleToggle = () => {
if (!open && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
const top = rect.bottom + 2;
// popover 높이 = 화면 안에 들어맞게. 너무 크지 않게 360 상한.
const maxHeight = Math.min(360, window.innerHeight - top - 16);
setPopoverPos({
top,
left: rect.left,
width: rect.width,
maxHeight: Math.max(180, maxHeight),
});
}
setOpen((v) => !v);
};
// popover 떠 있는 동안 외부 scroll / resize 발생 시 닫기. 단, popover 안의
// 그리드 자체 스크롤은 제외 — 휠로 아이콘 목록 스크롤 시 닫히지 않게.
useEffect(() => {
if (!open) return;
const onScroll = (e: Event) => {
const target = e.target;
if (target instanceof Node && popoverRef.current?.contains(target)) {
return; // popover 내부 스크롤은 무시
}
setOpen(false);
};
const onResize = () => setOpen(false);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open]);
const filtered = useMemo(() => {
if (!search.trim()) {
const popularSet = new Set(POPULAR);
const popular = ICON_ENTRIES.filter(([n]) => popularSet.has(n));
const rest = ICON_ENTRIES.filter(([n]) => !popularSet.has(n)).slice(0, 300 - popular.length);
return [...popular, ...rest];
}
const raw = search.trim();
const q = raw.toLowerCase();
const hasHangul = /[가-힣]/.test(raw);
return ICON_ENTRIES.filter(([name]) => {
// 영문 lucide 이름 매치
if (name.toLowerCase().includes(q)) return true;
// 한글 alias 매치 (한글 검색 시점 우선)
const aliases = ICON_KO[name];
if (aliases) {
for (const ko of aliases) {
if (hasHangul ? ko.includes(raw) : ko.toLowerCase().includes(q)) return true;
}
}
return false;
}).slice(0, 300);
}, [search]);
const SelectedIcon = value ? (LucideIcons as any)[value] : null;
return (
<div style={{ position: "relative" }} className={className}>
<button
ref={triggerRef}
type="button"
onClick={handleToggle}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{
width: "100%",
height: 28,
padding: "0 8px",
fontSize: 12,
background: "var(--cp-surface)",
border: `1px solid ${
focused
? "hsl(var(--primary) / 0.5)"
: "var(--cp-border)"
}`,
borderRadius: 6,
color: "var(--cp-text)",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 6,
fontFamily: "var(--v5-font-sans)",
boxShadow: focused
? "0 0 0 3px hsl(var(--primary) / 0.12)"
: undefined,
transition: "border-color .14s ease, box-shadow .14s ease",
textAlign: "left",
}}
>
{SelectedIcon ? (
<>
<SelectedIcon size={14} />
<span
style={{
flex: 1,
fontFamily: "var(--v5-font-mono)",
fontSize: 11,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{value}
</span>
</>
) : (
<span style={{ flex: 1, color: "var(--cp-text-muted)" }}>
...
</span>
)}
<LucideIcons.ChevronDown
size={12}
style={{ color: "var(--cp-text-muted)", flexShrink: 0 }}
/>
</button>
{open && popoverPos && createPortal(
<div
ref={popoverRef}
style={{
position: "fixed",
top: popoverPos.top,
left: popoverPos.left,
width: popoverPos.width,
height: popoverPos.maxHeight,
background: "var(--cp-surface)",
border: "1px solid var(--cp-border)",
borderRadius: 6,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.12)",
zIndex: 9999,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
{/* 검색 */}
<div
style={{
padding: 6,
borderBottom: "1px solid var(--cp-border-subtle)",
}}
>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="아이콘 검색 (한/영) — 사원, users…"
autoFocus
style={{
width: "100%",
height: 24,
padding: "0 8px",
fontSize: 11,
background: "var(--cp-bg-subtle)",
border: "1px solid var(--cp-border)",
borderRadius: 4,
color: "var(--cp-text)",
outline: "none",
fontFamily: "var(--v5-font-sans)",
}}
/>
</div>
{/* 그리드 — popover 안에서 flex 로 늘어나며 휠 스크롤 활성 */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(6, 1fr)",
gap: 2,
padding: 6,
overflowY: "auto",
minHeight: 0,
flex: 1,
}}
>
{/* 선택 해제 */}
<button
type="button"
onClick={() => {
onChange("");
setOpen(false);
}}
title="없음"
style={{
height: 28,
background: "transparent",
border: "none",
cursor: "pointer",
color: "var(--cp-text-muted)",
fontSize: 9,
borderRadius: 4,
}}
>
--
</button>
{filtered.map(([name, Icon]) => {
const IconComp = Icon as React.FC<{ size?: number }>;
const active = name === value;
return (
<button
key={name}
type="button"
onClick={() => {
onChange(name);
setOpen(false);
setSearch("");
}}
title={name}
style={{
height: 28,
background: active
? "hsl(var(--primary) / 0.10)"
: "transparent",
border: "none",
cursor: "pointer",
color: active ? "hsl(var(--primary))" : "var(--cp-text)",
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "background .12s ease",
}}
onMouseEnter={(e) => {
if (!active)
(e.currentTarget as HTMLButtonElement).style.background =
"var(--cp-surface-hover, rgba(0,0,0,0.04))";
}}
onMouseLeave={(e) => {
if (!active)
(e.currentTarget as HTMLButtonElement).style.background =
"transparent";
}}
>
<IconComp size={14} />
</button>
);
})}
</div>
{filtered.length === 0 && (
<div
style={{
padding: 12,
textAlign: "center",
color: "var(--cp-text-muted)",
fontSize: 10,
}}
>
&ldquo;{search}&rdquo;
</div>
)}
</div>,
document.body,
)}
</div>
);
};
export default IconPicker;