feat: 대시보드 페이지 구현 및 공통 컴포넌트 추가 (#9) #11

Merged
seonkyu.kim merged 3 commits from feature/9-dashboard into develop 2026-02-27 01:29:18 +00:00
17 changed files with 780 additions and 35 deletions
Showing only changes of commit af6ecab428 - Show all commits

View File

@ -0,0 +1,27 @@
type CategoryVariant = "success" | "warning" | "error" | "info" | "default";
interface CategoryBadgeProps {
variant: CategoryVariant;
icon: string;
label: string;
}
const VARIANT_STYLES: Record<CategoryVariant, string> = {
success: "bg-green-50 text-green-700",
warning: "bg-amber-50 text-amber-700",
error: "bg-red-50 text-red-700",
info: "bg-blue-50 text-blue-700",
default: "bg-gray-100 text-gray-600",
};
/** 카테고리 뱃지 (아이콘 + 텍스트 패턴) */
export default function CategoryBadge({ variant, icon, label }: CategoryBadgeProps) {
return (
<span
className={`inline-flex flex-shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold ${VARIANT_STYLES[variant]}`}
>
<span className="material-symbols-outlined" style={{ fontSize: "10px" }}>{icon}</span>
{label}
</span>
);
}

View File

@ -0,0 +1,37 @@
import { useState } from "react";
import { toast } from "sonner";
interface CopyButtonProps {
text: string;
}
/** 클립보드 복사 + 피드백 */
export default function CopyButton({ text }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success("클립보드에 복사되었습니다");
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("복사에 실패했습니다");
}
};
return (
<button
onClick={handleCopy}
className="inline-flex items-center justify-center size-7 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
title="복사"
>
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
{copied ? "check" : "content_copy"}
</span>
</button>
);
}

View File

@ -0,0 +1,70 @@
interface DateRangeInputProps {
startDate: string;
endDate: string;
onStartChange: (value: string) => void;
onEndChange: (value: string) => void;
startLabel?: string;
endLabel?: string;
}
/** 시작일~종료일 날짜 입력 (자동 보정) */
export default function DateRangeInput({
startDate,
endDate,
onStartChange,
onEndChange,
startLabel = "시작일",
endLabel = "종료일",
}: DateRangeInputProps) {
const handleStartChange = (value: string) => {
onStartChange(value);
if (endDate && value > endDate) onEndChange(value);
};
const handleEndChange = (value: string) => {
onEndChange(value);
if (startDate && value < startDate) onStartChange(value);
};
return (
<>
{/* 시작일 */}
<div className="flex-1">
<label className="block text-xs font-medium text-gray-600 mb-1.5">
{startLabel}
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-gray-400 text-base">
calendar_today
</span>
<input
type="date"
value={startDate}
onChange={(e) => handleStartChange(e.target.value)}
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
/>
</div>
</div>
<span className="text-gray-400 text-sm font-medium pb-2">~</span>
{/* 종료일 */}
<div className="flex-1">
<label className="block text-xs font-medium text-gray-600 mb-1.5">
{endLabel}
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-gray-400 text-base">
calendar_today
</span>
<input
type="date"
value={endDate}
onChange={(e) => handleEndChange(e.target.value)}
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
/>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,27 @@
import type { ReactNode } from "react";
interface EmptyStateProps {
icon?: string;
message: string;
description?: string;
action?: ReactNode;
}
/** 빈 데이터 상태 표시 */
export default function EmptyState({ icon = "inbox", message, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<span
className="material-symbols-outlined text-gray-300 mb-4"
style={{ fontSize: "48px" }}
>
{icon}
</span>
<p className="text-sm font-medium text-gray-500 mb-1">{message}</p>
{description && (
<p className="text-xs text-gray-400 mb-4">{description}</p>
)}
{action && <div className="mt-2">{action}</div>}
</div>
);
}

View File

@ -0,0 +1,70 @@
import { useState, useRef, useEffect } from "react";
interface FilterDropdownProps {
label?: string;
value: string;
options: string[];
onChange: (value: string) => void;
className?: string;
}
/** 커스텀 셀렉트 드롭다운 (외부클릭 닫기) */
export default function FilterDropdown({
label,
value,
options,
onChange,
className = "",
}: FilterDropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// 외부 클릭 시 닫기
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
return (
<div className={`relative ${className}`} ref={ref}>
{label && (
<label className="block text-xs font-medium text-gray-600 mb-1.5">
{label}
</label>
)}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full h-[38px] border border-gray-300 rounded-lg px-3 text-sm text-left flex items-center justify-between bg-white hover:border-gray-400 transition-colors"
>
<span className="truncate">{value}</span>
<span className="material-symbols-outlined text-gray-400 text-[18px] flex-shrink-0">
expand_more
</span>
</button>
{open && (
<ul className="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg z-50 py-1 overflow-hidden">
{options.map((opt) => (
<li
key={opt}
onClick={() => {
onChange(opt);
setOpen(false);
}}
className={`px-3 py-2 text-sm hover:bg-gray-50 cursor-pointer transition-colors ${
opt === value ? "text-primary font-medium" : ""
}`}
>
{opt}
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,30 @@
import { useState } from "react";
interface FilterResetButtonProps {
onClick: () => void;
}
/** 초기화 버튼 (스핀 애니메이션 + 툴팁) */
export default function FilterResetButton({ onClick }: FilterResetButtonProps) {
const [spinning, setSpinning] = useState(false);
const handleClick = () => {
onClick();
setSpinning(true);
setTimeout(() => setSpinning(false), 500);
};
return (
<button
onClick={handleClick}
className="filter-reset-btn h-[38px] w-[38px] flex-shrink-0 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:text-red-500 hover:border-red-300 hover:bg-red-50 transition-colors relative"
>
<span className="reset-tooltip"> </span>
<span
className={`material-symbols-outlined text-lg${spinning ? " spinning" : ""}`}
>
restart_alt
</span>
</button>
);
}

View File

@ -0,0 +1,22 @@
import type { ReactNode } from "react";
interface PageHeaderProps {
title: string;
description?: string;
action?: ReactNode;
}
/** 페이지 제목 + 설명 + 우측 액션 버튼 */
export default function PageHeader({ title, description, action }: PageHeaderProps) {
return (
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-[#0f172a]">{title}</h1>
{description && (
<p className="text-[#64748b] text-sm mt-1">{description}</p>
)}
</div>
{action && <div>{action}</div>}
</div>
);
}

View File

@ -0,0 +1,103 @@
interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
pageSize: number;
onPageChange: (page: number) => void;
}
/** 테이블 페이지네이션 */
export default function Pagination({
currentPage,
totalPages,
totalItems,
pageSize,
onPageChange,
}: PaginationProps) {
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, totalItems);
/** 표시할 페이지 번호 목록 (최대 5개) */
const getPageNumbers = () => {
const pages: number[] = [];
let startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, startPage + 4);
startPage = Math.max(1, endPage - 4);
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
if (totalPages <= 0) return null;
return (
<div className="flex items-center justify-between px-2 py-3">
<span className="text-sm text-gray-500">
{totalItems.toLocaleString()} {start.toLocaleString()}-{end.toLocaleString()}
</span>
<div className="flex items-center gap-1">
{/* 처음 */}
<button
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
first_page
</span>
</button>
{/* 이전 */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
chevron_left
</span>
</button>
{/* 페이지 번호 */}
{getPageNumbers().map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`size-8 flex items-center justify-center rounded-lg text-sm font-medium transition-colors ${
page === currentPage
? "bg-primary text-white"
: "text-gray-600 hover:bg-gray-100"
}`}
>
{page}
</button>
))}
{/* 다음 */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
chevron_right
</span>
</button>
{/* 마지막 */}
<button
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
className="size-8 flex items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: "18px" }}>
last_page
</span>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
interface PlatformBadgeProps {
platform: "ios" | "android";
}
/** iOS/Android 아이콘 뱃지 */
export default function PlatformBadge({ platform }: PlatformBadgeProps) {
if (platform === "ios") {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 border border-gray-200">
<svg className="size-3" viewBox="0 0 384 512" fill="currentColor">
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
</svg>
iOS
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
android
</span>
Android
</span>
);
}

View File

@ -0,0 +1,36 @@
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
label?: string;
}
/** 검색 아이콘 + 텍스트 입력 */
export default function SearchInput({
value,
onChange,
placeholder = "검색...",
label,
}: SearchInputProps) {
return (
<div className="flex-1">
{label && (
<label className="block text-xs font-medium text-gray-600 mb-1.5">
{label}
</label>
)}
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-gray-400 text-base">
search
</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
type StatusVariant = "success" | "error" | "warning" | "info" | "default";
interface StatusBadgeProps {
variant: StatusVariant;
label: string;
}
const VARIANT_STYLES: Record<StatusVariant, { badge: string; dot: string }> = {
success: {
badge: "bg-green-50 text-green-700 border-green-200",
dot: "bg-green-500",
},
error: {
badge: "bg-red-50 text-red-700 border-red-200",
dot: "bg-red-500",
},
warning: {
badge: "bg-yellow-50 text-yellow-700 border-yellow-200",
dot: "bg-yellow-500",
},
info: {
badge: "bg-blue-50 text-blue-700 border-blue-200",
dot: "bg-blue-500",
},
default: {
badge: "bg-gray-100 text-gray-600 border-gray-200",
dot: "bg-gray-400",
},
};
/** 상태 dot 뱃지 (완료/실패/진행/예약/활성/비활성) */
export default function StatusBadge({ variant, label }: StatusBadgeProps) {
const style = VARIANT_STYLES[variant];
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border ${style.badge}`}
>
<span className={`size-1.5 rounded-full ${style.dot}`} />
{label}
</span>
);
}

View File

@ -1,8 +1,9 @@
import { useState, useRef, useEffect } from "react";
import { Link, useLocation } from "react-router-dom";
import CategoryBadge from "@/components/common/CategoryBadge";
/** 경로 → breadcrumb 레이블 매핑 */
const pathLabels: Record<string, string> = {
"/": "Home",
"/dashboard": "대시보드",
"/services": "서비스 관리",
"/services/register": "서비스 등록",
@ -17,36 +18,139 @@ const pathLabels: Record<string, string> = {
"/settings/notifications": "알림",
};
/** pathname → breadcrumb 배열 생성 (누적 경로 기반) */
function buildBreadcrumbs(pathname: string) {
const segments = pathname.split("/").filter(Boolean);
const crumbs: { path: string; label: string }[] = [];
let currentPath = "";
for (const segment of segments) {
currentPath += `/${segment}`;
const label = pathLabels[currentPath];
if (label) {
crumbs.push({ path: currentPath, label });
}
}
return crumbs;
}
/* ── 알림 더미 데이터 ── */
interface NotificationItem {
variant: "success" | "warning" | "error" | "info" | "default";
icon: string;
category: string;
title: string;
description: string;
time: string;
}
const MOCK_NOTIFICATIONS: NotificationItem[] = [
{ variant: "success", icon: "check_circle", category: "발송", title: "마케팅 발송", description: "마케팅 캠페인 발송이 완료되었습니다 (1,234건 중 1,230건 성공)", time: "14:30" },
{ variant: "warning", icon: "verified", category: "인증서", title: "iOS Production", description: "인증서가 7일 후 만료 예정입니다. 갱신이 필요합니다.", time: "09:15" },
{ variant: "error", icon: "error", category: "실패", title: "프로모션 알림", description: "FCM 토큰 만료로 인해 320건 발송에 실패했습니다", time: "1일 전" },
{ variant: "info", icon: "cloud", category: "서비스", title: "마케팅 발송", description: "신규 서비스가 정상 등록되었습니다", time: "2일 전" },
{ variant: "default", icon: "settings", category: "시스템", title: "SPMS", description: "정기 점검이 완료되어 정상 운영 중입니다", time: "3일 전" },
];
export default function AppHeader() {
const location = useLocation();
const isHome = location.pathname === "/";
const currentLabel = pathLabels[location.pathname] ?? "페이지";
const { pathname } = useLocation();
const crumbs = buildBreadcrumbs(pathname);
const [notiOpen, setNotiOpen] = useState(false);
const notiRef = useRef<HTMLDivElement>(null);
// 외부 클릭 시 드롭다운 닫기
useEffect(() => {
function handleClick(e: MouseEvent) {
if (notiRef.current && !notiRef.current.contains(e.target as Node)) {
setNotiOpen(false);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
// 페이지 이동 시 닫기
useEffect(() => {
setNotiOpen(false);
}, [pathname]);
return (
<header className="fixed left-64 right-0 top-0 z-40 flex h-16 items-center justify-between border-b border-gray-200 bg-white px-10 shadow-sm">
{/* 브레드크럼 */}
<nav className="flex items-center text-sm">
{isHome ? (
{/* 브레드크럼 (다단계) */}
<nav className="flex min-w-0 flex-1 items-center overflow-hidden text-sm">
{crumbs.length === 0 ? (
<span className="font-medium text-foreground">Home</span>
) : (
<>
<Link to="/" className="text-gray-500 transition-colors hover:text-primary">Home</Link>
<Link to="/" className="flex-shrink-0 whitespace-nowrap text-gray-500 transition-colors hover:text-primary">Home</Link>
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
return (
<span key={crumb.path} className="flex flex-shrink-0 items-center">
<span className="material-symbols-outlined mx-2 text-base text-gray-300">chevron_right</span>
<span className="font-medium text-foreground">{currentLabel}</span>
{isLast ? (
<span className="whitespace-nowrap font-medium text-foreground">{crumb.label}</span>
) : (
<Link to={crumb.path} className="whitespace-nowrap text-gray-500 transition-colors hover:text-primary">
{crumb.label}
</Link>
)}
</span>
);
})}
</>
)}
</nav>
{/* 우측 아이콘 */}
<div className="ml-auto flex flex-shrink-0 items-center gap-5 text-gray-400">
{/* 알림 */}
<button className="group relative transition-colors hover:text-primary">
<div className="ml-auto flex flex-shrink-0 items-center gap-5 pl-4 text-gray-400">
{/* 알림 버튼 + 드롭다운 */}
<div className="relative" ref={notiRef}>
<button
onClick={() => setNotiOpen((v) => !v)}
className="group relative transition-colors hover:text-primary"
>
<span className="material-symbols-outlined text-2xl group-hover:animate-pulse">
notifications
</span>
<span className="absolute right-0.5 top-0.5 size-2 rounded-full border-2 border-white bg-red-500" />
</button>
{/* 알림 드롭다운 패널 */}
{notiOpen && (
<div className="absolute right-0 top-full mt-2 w-96 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg z-50">
{/* 헤더 */}
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
<h3 className="text-sm font-bold text-foreground"></h3>
<Link
to="/settings/notifications"
className="text-xs font-medium text-primary transition-colors hover:text-[#1d4ed8]"
>
</Link>
</div>
{/* 알림 목록 */}
<div className="overflow-y-auto" style={{ maxHeight: 200, overscrollBehavior: "contain" }}>
{MOCK_NOTIFICATIONS.map((noti, i) => (
<div
key={i}
className="cursor-pointer border-b border-gray-50 px-4 py-3 transition-colors hover:bg-gray-50"
>
<div className="mb-1 flex items-center gap-2">
<CategoryBadge variant={noti.variant} icon={noti.icon} label={noti.category} />
<p className="flex-1 truncate text-xs font-bold text-foreground">{noti.title}</p>
<span className="flex-shrink-0 text-[10px] text-gray-400">{noti.time}</span>
</div>
<p className="truncate text-[11px] text-gray-500">{noti.description}</p>
</div>
))}
</div>
</div>
)}
</div>
{/* 구분선 */}
<div className="h-6 w-px bg-gray-200" />

View File

@ -1,8 +1,12 @@
import { useState } from "react";
import { Outlet } from "react-router-dom";
import AppSidebar from "./AppSidebar";
import AppHeader from "./AppHeader";
export default function AppLayout() {
const [termsModal, setTermsModal] = useState(false);
const [privacyModal, setPrivacyModal] = useState(false);
return (
<div className="flex min-h-screen w-full flex-row overflow-x-hidden">
{/* 사이드바 (w-64 고정) */}
@ -22,12 +26,84 @@ export default function AppLayout() {
<footer className="mt-auto flex items-center justify-between border-t border-gray-100 px-10 py-6 text-sm text-gray-400">
<p>&copy; 2026 Team.Stein. All rights reserved.</p>
<div className="flex items-center gap-4">
<a href="#" className="transition-colors hover:text-foreground"></a>
<button
onClick={() => setPrivacyModal(true)}
className="transition-colors hover:text-foreground"
>
</button>
<span className="text-gray-300">|</span>
<a href="#" className="transition-colors hover:text-foreground"></a>
<button
onClick={() => setTermsModal(true)}
className="transition-colors hover:text-foreground"
>
</button>
</div>
</footer>
</main>
{/* ───── 서비스 이용약관 모달 ───── */}
{termsModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) setTermsModal(false);
}}
>
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
<span className="material-symbols-outlined text-xl text-blue-600">
description
</span>
</div>
<h3 className="flex-1 text-lg font-bold text-foreground">
</h3>
<button
type="button"
onClick={() => setTermsModal(false)}
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
>
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button>
</div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div>
</div>
)}
{/* ───── 개인정보 처리방침 모달 ───── */}
{privacyModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) setPrivacyModal(false);
}}
>
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex size-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100">
<span className="material-symbols-outlined text-xl text-blue-600">
shield
</span>
</div>
<h3 className="flex-1 text-lg font-bold text-foreground">
</h3>
<button
type="button"
onClick={() => setPrivacyModal(false)}
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
>
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button>
</div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div>
</div>
)}
</div>
);
}

View File

@ -171,11 +171,11 @@ export default function AppSidebar() {
<div className="flex size-9 items-center justify-center rounded-full bg-blue-600 text-xs font-bold tracking-wide text-white shadow-sm">
{user?.name?.slice(0, 2)?.toUpperCase() ?? "AU"}
</div>
<div className="flex-1 overflow-hidden">
<div className="flex-1 overflow-hidden" style={{ containerType: "inline-size" }}>
<p className="truncate text-sm font-medium text-white">
{user?.name ?? "Admin User"}
</p>
<p className="truncate text-xs text-gray-400">
<p className="text-gray-400" style={{ fontSize: "clamp(9px, 2.5cqi, 12px)" }}>
{user?.email ?? "admin@spms.com"}
</p>
</div>

View File

@ -464,20 +464,18 @@ export default function SignupPage() {
description
</span>
</div>
<h3 className="text-lg font-bold text-foreground">
<h3 className="flex-1 text-lg font-bold text-foreground">
</h3>
</div>
<div className="mb-5 h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setTermsModal(false)}
className="rounded border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-foreground transition hover:bg-gray-50"
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
>
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button>
</div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div>
</div>
)}
@ -497,20 +495,18 @@ export default function SignupPage() {
shield
</span>
</div>
<h3 className="text-lg font-bold text-foreground">
<h3 className="flex-1 text-lg font-bold text-foreground">
</h3>
</div>
<div className="mb-5 h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setPrivacyModal(false)}
className="rounded border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-foreground transition hover:bg-gray-50"
className="flex-shrink-0 rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-foreground"
>
<span className="material-symbols-outlined" style={{ fontSize: "20px" }}>close</span>
</button>
</div>
<div className="h-72 w-full rounded-lg border border-gray-200 bg-gray-50" />
</div>
</div>
)}

View File

@ -139,3 +139,70 @@
.animate-shake {
animation: shake 0.4s ease;
}
/* 필터 초기화 버튼 스핀 */
@keyframes spin-once {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-360deg);
}
}
.filter-reset-btn .spinning {
animation: spin-once 0.5s ease;
}
.filter-reset-btn .reset-tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%) translateY(4px);
background: #1f2937;
color: #fff;
font-size: 11px;
padding: 4px 10px;
border-radius: 6px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity 0.15s,
transform 0.15s;
pointer-events: none;
z-index: 50;
}
.filter-reset-btn .reset-tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: #1f2937;
}
.filter-reset-btn:hover .reset-tooltip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
/* 차트 공통 스타일 */
.chart-dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
background: white;
border: 2.5px solid;
transform: translate(-50%, -50%) scale(0);
box-sizing: border-box;
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.chart-dot.show {
transform: translate(-50%, -50%) scale(1);
}

View File

@ -11,7 +11,15 @@ import { useAuthStore } from "@/stores/authStore";
function lazyPage(importFn: () => Promise<{ default: ComponentType }>) {
const LazyComponent = lazy(importFn);
return (
<Suspense fallback={<div className="flex h-full items-center justify-center p-6"> ...</div>}>
<Suspense fallback={
<div className="flex h-full flex-col items-center justify-center gap-3">
<svg className="size-9 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm text-gray-400">Loading...</span>
</div>
}>
<LazyComponent />
</Suspense>
);