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:
parent
3933f1e966
commit
4176fed07f
|
|
@ -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 전환 / 로그아웃",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
|||
"pop-string-list": "리스트 목록",
|
||||
"pop-search": "검색",
|
||||
"pop-field": "입력",
|
||||
"pop-profile": "프로필",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -23,6 +23,4 @@ import "./pop-search";
|
|||
|
||||
import "./pop-field";
|
||||
import "./pop-scanner";
|
||||
|
||||
// 향후 추가될 컴포넌트들:
|
||||
// import "./pop-list";
|
||||
import "./pop-profile";
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
});
|
||||
Loading…
Reference in New Issue