feat: 대시보드 페이지 구현 및 공통 컴포넌트 추가 (#9) #11
27
react/src/components/common/CategoryBadge.tsx
Normal file
27
react/src/components/common/CategoryBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
react/src/components/common/CopyButton.tsx
Normal file
37
react/src/components/common/CopyButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
react/src/components/common/DateRangeInput.tsx
Normal file
70
react/src/components/common/DateRangeInput.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
react/src/components/common/EmptyState.tsx
Normal file
27
react/src/components/common/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
react/src/components/common/FilterDropdown.tsx
Normal file
70
react/src/components/common/FilterDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
react/src/components/common/FilterResetButton.tsx
Normal file
30
react/src/components/common/FilterResetButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
react/src/components/common/PageHeader.tsx
Normal file
22
react/src/components/common/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
react/src/components/common/Pagination.tsx
Normal file
103
react/src/components/common/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
react/src/components/common/PlatformBadge.tsx
Normal file
29
react/src/components/common/PlatformBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
react/src/components/common/SearchInput.tsx
Normal file
36
react/src/components/common/SearchInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
react/src/components/common/StatusBadge.tsx
Normal file
43
react/src/components/common/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>© 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user