feat(pop): pop-profile 컴포넌트 추가 - 사용자 프로필/PC전환/로그아웃

디자이너 팔레트에서 배치 가능한 10번째 POP 컴포넌트로 pop-profile을
추가한다. 화면 설계자가 필요한 화면에만 프로필 기능을 배치할 수 있도록
시스템 UI가 아닌 컴포넌트 등록 방식으로 구현한다.
[뷰어 컴포넌트]
- useAuth() 연동으로 실제 로그인 사용자 정보 표시
- Popover 드롭다운: 사용자 정보 + POP 대시보드 / PC 모드 / 로그아웃
- 비로그인 시 로그인 버튼 표시
- 아바타: 사용자 사진 또는 이름 이니셜 표시
[설정 패널]
- 아바타 크기 선택 (sm 32px / md 40px / lg 48px)
- 메뉴 항목 개별 on/off (대시보드 이동 / PC 모드 전환 / 로그아웃)
[디자이너 통합]
- PopComponentType에 "pop-profile" 추가
- DEFAULT_COMPONENT_GRID_SIZE: 1x1
- PALETTE_ITEMS: UserCircle 아이콘 + 설명
- COMPONENT_TYPE_LABELS: "프로필"
- PopComponentRegistry 등록 (category: action)
This commit is contained in:
SeongHyun Kim 2026-03-09 13:21:18 +09:00
parent 3933f1e966
commit 4176fed07f
5 changed files with 347 additions and 5 deletions

View File

@ -3,7 +3,7 @@
import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine } from "lucide-react";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의
@ -75,6 +75,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: ScanLine,
description: "바코드/QR 카메라 스캔",
},
{
type: "pop-profile",
label: "프로필",
icon: UserCircle,
description: "사용자 프로필 / PC 전환 / 로그아웃",
},
];
// 드래그 가능한 컴포넌트 아이템

View File

@ -76,6 +76,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-string-list": "리스트 목록",
"pop-search": "검색",
"pop-field": "입력",
"pop-profile": "프로필",
};
// ========================================

View File

@ -9,7 +9,7 @@
/**
* POP
*/
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field" | "pop-scanner";
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field" | "pop-scanner" | "pop-profile";
/**
*
@ -363,6 +363,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
"pop-search": { colSpan: 2, rowSpan: 1 },
"pop-field": { colSpan: 6, rowSpan: 2 },
"pop-scanner": { colSpan: 1, rowSpan: 1 },
"pop-profile": { colSpan: 1, rowSpan: 1 },
};
/**

View File

@ -23,6 +23,4 @@ import "./pop-search";
import "./pop-field";
import "./pop-scanner";
// 향후 추가될 컴포넌트들:
// import "./pop-list";
import "./pop-profile";

View File

@ -0,0 +1,336 @@
"use client";
import React, { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Monitor, LayoutGrid, LogOut, UserCircle } from "lucide-react";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { useAuth } from "@/hooks/useAuth";
// ========================================
// 타입 정의
// ========================================
type AvatarSize = "sm" | "md" | "lg";
export interface PopProfileConfig {
avatarSize?: AvatarSize;
showDashboardLink?: boolean;
showPcMode?: boolean;
showLogout?: boolean;
}
const DEFAULT_CONFIG: PopProfileConfig = {
avatarSize: "md",
showDashboardLink: true,
showPcMode: true,
showLogout: true,
};
const AVATAR_SIZE_MAP: Record<AvatarSize, { container: string; text: string; px: number }> = {
sm: { container: "h-8 w-8", text: "text-sm", px: 32 },
md: { container: "h-10 w-10", text: "text-base", px: 40 },
lg: { container: "h-12 w-12", text: "text-lg", px: 48 },
};
const AVATAR_SIZE_LABELS: Record<AvatarSize, string> = {
sm: "작은 (32px)",
md: "보통 (40px)",
lg: "큰 (48px)",
};
// ========================================
// 뷰어 컴포넌트
// ========================================
interface PopProfileComponentProps {
config?: PopProfileConfig;
componentId?: string;
screenId?: string;
}
function PopProfileComponent({ config: rawConfig }: PopProfileComponentProps) {
const router = useRouter();
const { user, isLoggedIn, logout } = useAuth();
const [open, setOpen] = useState(false);
const config = useMemo(() => ({
...DEFAULT_CONFIG,
...rawConfig,
}), [rawConfig]);
const sizeInfo = AVATAR_SIZE_MAP[config.avatarSize || "md"];
const initial = user?.userName?.substring(0, 1)?.toUpperCase() || "?";
const handlePcMode = () => {
setOpen(false);
router.push("/");
};
const handleDashboard = () => {
setOpen(false);
router.push("/pop");
};
const handleLogout = async () => {
setOpen(false);
await logout();
};
const handleLogin = () => {
setOpen(false);
router.push("/login");
};
return (
<div className="flex h-full w-full items-center justify-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"flex items-center justify-center rounded-full",
"bg-primary text-primary-foreground font-bold",
"border-2 border-primary/20 cursor-pointer",
"transition-all duration-150",
"hover:scale-105 hover:border-primary/40",
"active:scale-95",
sizeInfo.container,
sizeInfo.text,
)}
style={{ minWidth: sizeInfo.px, minHeight: sizeInfo.px }}
>
{user?.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<img
src={user.photo}
alt={user.userName || "User"}
className="h-full w-full rounded-full object-cover"
/>
) : (
initial
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-60 p-0"
align="end"
sideOffset={8}
>
{isLoggedIn && user ? (
<>
{/* 사용자 정보 */}
<div className="flex items-center gap-3 border-b p-4">
<div className={cn(
"flex shrink-0 items-center justify-center rounded-full",
"bg-primary text-primary-foreground font-bold",
"h-10 w-10 text-base",
)}>
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
<img
src={user.photo}
alt={user.userName || "User"}
className="h-full w-full rounded-full object-cover"
/>
) : (
initial
)}
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-semibold">
{user.userName || "사용자"} ({user.userId || ""})
</span>
<span className="truncate text-xs text-muted-foreground">
{user.deptName || "부서 정보 없음"}
</span>
</div>
</div>
{/* 메뉴 항목 */}
<div className="p-1.5">
{config.showDashboardLink && (
<button
onClick={handleDashboard}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm transition-colors hover:bg-accent"
style={{ minHeight: 48 }}
>
<LayoutGrid className="h-4 w-4 text-muted-foreground" />
POP
</button>
)}
{config.showPcMode && (
<button
onClick={handlePcMode}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm transition-colors hover:bg-accent"
style={{ minHeight: 48 }}
>
<Monitor className="h-4 w-4 text-muted-foreground" />
PC
</button>
)}
{config.showLogout && (
<>
<div className="mx-2 my-1 border-t" />
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-md px-3 py-3 text-sm text-destructive transition-colors hover:bg-destructive/10"
style={{ minHeight: 48 }}
>
<LogOut className="h-4 w-4" />
</button>
</>
)}
</div>
</>
) : (
<div className="p-4">
<p className="mb-3 text-center text-sm text-muted-foreground">
</p>
<button
onClick={handleLogin}
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-3 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
style={{ minHeight: 48 }}
>
</button>
</div>
)}
</PopoverContent>
</Popover>
</div>
);
}
// ========================================
// 설정 패널
// ========================================
interface PopProfileConfigPanelProps {
config: PopProfileConfig;
onUpdate: (config: PopProfileConfig) => void;
}
function PopProfileConfigPanel({ config: rawConfig, onUpdate }: PopProfileConfigPanelProps) {
const config = useMemo(() => ({
...DEFAULT_CONFIG,
...rawConfig,
}), [rawConfig]);
const updateConfig = (partial: Partial<PopProfileConfig>) => {
onUpdate({ ...config, ...partial });
};
return (
<div className="space-y-4 p-3">
{/* 아바타 크기 */}
<div className="space-y-1.5">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={config.avatarSize || "md"}
onValueChange={(v) => updateConfig({ avatarSize: v as AvatarSize })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(AVATAR_SIZE_LABELS) as [AvatarSize, string][]).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs sm:text-sm">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 메뉴 항목 토글 */}
<div className="space-y-3">
<Label className="text-xs sm:text-sm"> </Label>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">POP </Label>
<Switch
checked={config.showDashboardLink ?? true}
onCheckedChange={(v) => updateConfig({ showDashboardLink: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">PC </Label>
<Switch
checked={config.showPcMode ?? true}
onCheckedChange={(v) => updateConfig({ showPcMode: v })}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground"></Label>
<Switch
checked={config.showLogout ?? true}
onCheckedChange={(v) => updateConfig({ showLogout: v })}
/>
</div>
</div>
</div>
);
}
// ========================================
// 디자이너 미리보기
// ========================================
function PopProfilePreview({ config }: { config?: PopProfileConfig }) {
const size = AVATAR_SIZE_MAP[config?.avatarSize || "md"];
return (
<div className="flex h-full w-full items-center justify-center gap-2">
<div className={cn(
"flex items-center justify-center rounded-full",
"bg-primary/20 text-primary",
size.container, size.text,
)}>
<UserCircle className="h-5 w-5" />
</div>
<span className="text-xs text-muted-foreground"></span>
</div>
);
}
// ========================================
// 레지스트리 등록
// ========================================
PopComponentRegistry.registerComponent({
id: "pop-profile",
name: "프로필",
description: "사용자 프로필 / PC 전환 / 로그아웃",
category: "action",
icon: "UserCircle",
component: PopProfileComponent,
configPanel: PopProfileConfigPanel,
preview: PopProfilePreview,
defaultProps: {
avatarSize: "md",
showDashboardLink: true,
showPcMode: true,
showLogout: true,
},
connectionMeta: {
sendable: [],
receivable: [],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});