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 { useDrag } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PopComponentType } from "../types/pop-layout";
|
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";
|
import { DND_ITEM_TYPES } from "../constants";
|
||||||
|
|
||||||
// 컴포넌트 정의
|
// 컴포넌트 정의
|
||||||
|
|
@ -75,6 +75,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: ScanLine,
|
icon: ScanLine,
|
||||||
description: "바코드/QR 카메라 스캔",
|
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-string-list": "리스트 목록",
|
||||||
"pop-search": "검색",
|
"pop-search": "검색",
|
||||||
"pop-field": "입력",
|
"pop-field": "입력",
|
||||||
|
"pop-profile": "프로필",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 타입
|
* 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-search": { colSpan: 2, rowSpan: 1 },
|
||||||
"pop-field": { colSpan: 6, rowSpan: 2 },
|
"pop-field": { colSpan: 6, rowSpan: 2 },
|
||||||
"pop-scanner": { colSpan: 1, rowSpan: 1 },
|
"pop-scanner": { colSpan: 1, rowSpan: 1 },
|
||||||
|
"pop-profile": { colSpan: 1, rowSpan: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,4 @@ import "./pop-search";
|
||||||
|
|
||||||
import "./pop-field";
|
import "./pop-field";
|
||||||
import "./pop-scanner";
|
import "./pop-scanner";
|
||||||
|
import "./pop-profile";
|
||||||
// 향후 추가될 컴포넌트들:
|
|
||||||
// import "./pop-list";
|
|
||||||
|
|
|
||||||
|
|
@ -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