feat: 대시보드 페이지 구현 및 공통 컴포넌트 추가 (#9)
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/11
This commit is contained in:
commit
c89cfeaa56
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>
|
||||
);
|
||||
}
|
||||
74
react/src/components/common/DateRangeInput.tsx
Normal file
74
react/src/components/common/DateRangeInput.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
interface DateRangeInputProps {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
onStartChange: (value: string) => void;
|
||||
onEndChange: (value: string) => void;
|
||||
startLabel?: string;
|
||||
endLabel?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 시작일~종료일 날짜 입력 (자동 보정) */
|
||||
export default function DateRangeInput({
|
||||
startDate,
|
||||
endDate,
|
||||
onStartChange,
|
||||
onEndChange,
|
||||
startLabel = "시작일",
|
||||
endLabel = "종료일",
|
||||
disabled,
|
||||
}: 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)}
|
||||
disabled={disabled}
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||
/>
|
||||
</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)}
|
||||
disabled={disabled}
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
73
react/src/components/common/FilterDropdown.tsx
Normal file
73
react/src/components/common/FilterDropdown.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
interface FilterDropdownProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 커스텀 셀렉트 드롭다운 (외부클릭 닫기) */
|
||||
export default function FilterDropdown({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className = "",
|
||||
disabled,
|
||||
}: 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={() => !disabled && setOpen((v) => !v)}
|
||||
disabled={disabled}
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:hover:border-gray-300"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
32
react/src/components/common/FilterResetButton.tsx
Normal file
32
react/src/components/common/FilterResetButton.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useState } from "react";
|
||||
|
||||
interface FilterResetButtonProps {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 초기화 버튼 (스핀 애니메이션 + 툴팁) */
|
||||
export default function FilterResetButton({ onClick, disabled }: FilterResetButtonProps) {
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
onClick();
|
||||
setSpinning(true);
|
||||
setTimeout(() => setSpinning(false), 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-gray-500 disabled:hover:border-gray-300 disabled:hover:bg-transparent"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
|
|
|||
77
react/src/features/dashboard/components/DashboardFilter.tsx
Normal file
77
react/src/features/dashboard/components/DashboardFilter.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useState } from "react";
|
||||
import FilterDropdown from "@/components/common/FilterDropdown";
|
||||
import DateRangeInput from "@/components/common/DateRangeInput";
|
||||
import FilterResetButton from "@/components/common/FilterResetButton";
|
||||
|
||||
interface DashboardFilterProps {
|
||||
onSearch?: (filter: { dateStart: string; dateEnd: string; service: string }) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const SERVICES = ["전체 서비스", "쇼핑몰 앱", "배달 파트너"];
|
||||
|
||||
/** 오늘 날짜 YYYY-MM-DD */
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
/** 30일 전 */
|
||||
function thirtyDaysAgo() {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 30);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default function DashboardFilter({ onSearch, loading }: DashboardFilterProps) {
|
||||
const [dateStart, setDateStart] = useState(thirtyDaysAgo);
|
||||
const [dateEnd, setDateEnd] = useState(today);
|
||||
const [selectedService, setSelectedService] = useState(SERVICES[0]);
|
||||
|
||||
// 초기화
|
||||
const handleReset = () => {
|
||||
setDateStart(thirtyDaysAgo());
|
||||
setDateEnd(today());
|
||||
setSelectedService(SERVICES[0]);
|
||||
};
|
||||
|
||||
// 조회
|
||||
const handleSearch = () => {
|
||||
onSearch?.({ dateStart, dateEnd, service: selectedService });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||
<div className="flex items-end gap-4">
|
||||
{/* 날짜 범위 */}
|
||||
<DateRangeInput
|
||||
startDate={dateStart}
|
||||
endDate={dateEnd}
|
||||
onStartChange={setDateStart}
|
||||
onEndChange={setDateEnd}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* 서비스 드롭다운 */}
|
||||
<FilterDropdown
|
||||
label="서비스"
|
||||
value={selectedService}
|
||||
options={SERVICES}
|
||||
onChange={setSelectedService}
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* 초기화 */}
|
||||
<FilterResetButton onClick={handleReset} disabled={loading} />
|
||||
|
||||
{/* 조회 */}
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={loading}
|
||||
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
react/src/features/dashboard/components/PlatformDonut.tsx
Normal file
87
react/src/features/dashboard/components/PlatformDonut.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
interface PlatformData {
|
||||
ios: number;
|
||||
android: number;
|
||||
}
|
||||
|
||||
interface PlatformDonutProps {
|
||||
data?: PlatformData;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_DATA: PlatformData = { ios: 45, android: 55 };
|
||||
|
||||
export default function PlatformDonut({ data = DEFAULT_DATA, loading }: PlatformDonutProps) {
|
||||
const { ios, android } = data;
|
||||
|
||||
return (
|
||||
<div className="lg:col-span-1 bg-white rounded-xl shadow-sm border border-gray-200 p-6 flex flex-col relative">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 rounded-xl">
|
||||
<svg className="size-7 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>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-base font-bold text-[#0f172a] mb-6">플랫폼 비율</h2>
|
||||
|
||||
{/* 도넛 차트 */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center relative">
|
||||
<div className="relative size-48">
|
||||
<svg className="size-full" viewBox="0 0 36 36">
|
||||
{/* 배경 원 */}
|
||||
<path
|
||||
className="text-gray-100"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
{/* Android (teal) */}
|
||||
<path
|
||||
className="text-teal-400"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeDasharray={`${android}, 100`}
|
||||
strokeWidth="3"
|
||||
/>
|
||||
{/* iOS (primary blue) */}
|
||||
<path
|
||||
className="text-primary"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeDasharray={`${ios}, 100`}
|
||||
strokeDashoffset={`${-android}`}
|
||||
strokeWidth="3"
|
||||
/>
|
||||
</svg>
|
||||
{/* 중앙 텍스트 */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold text-gray-900">Total</span>
|
||||
<span className="text-sm text-gray-500">Devices</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="mt-8 flex flex-col gap-3 w-full px-4">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-3 rounded-full bg-primary flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-600">iOS</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-gray-900">{ios}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-3 rounded-full bg-teal-400 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-600">Android</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-gray-900">{android}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
react/src/features/dashboard/components/RecentMessages.tsx
Normal file
94
react/src/features/dashboard/components/RecentMessages.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import StatusBadge from "@/components/common/StatusBadge";
|
||||
|
||||
type StatusVariant = "success" | "error" | "warning" | "default";
|
||||
|
||||
const STATUS_MAP: Record<string, StatusVariant> = {
|
||||
완료: "success",
|
||||
실패: "error",
|
||||
진행: "warning",
|
||||
예약: "default",
|
||||
};
|
||||
|
||||
type MessageStatus = keyof typeof STATUS_MAP;
|
||||
|
||||
interface RecentMessage {
|
||||
template: string;
|
||||
targetCount: string;
|
||||
status: MessageStatus;
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
interface RecentMessagesProps {
|
||||
messages?: RecentMessage[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_MESSAGES: RecentMessage[] = [
|
||||
{ template: "가을맞이 프로모션 알림", targetCount: "12,405", status: "완료", sentAt: "2026-02-15 14:00" },
|
||||
{ template: "정기 점검 안내", targetCount: "45,100", status: "완료", sentAt: "2026-02-15 10:30" },
|
||||
{ template: "비밀번호 변경 알림", targetCount: "1", status: "실패", sentAt: "2026-02-15 09:15" },
|
||||
{ template: "신규 서비스 런칭", targetCount: "8,500", status: "진행", sentAt: "2026-02-15 09:00" },
|
||||
{ template: "야간 푸시 마케팅", targetCount: "3,200", status: "예약", sentAt: "2026-02-15 20:00" },
|
||||
];
|
||||
|
||||
export default function RecentMessages({ messages = DEFAULT_MESSAGES, loading }: RecentMessagesProps) {
|
||||
return (
|
||||
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col overflow-hidden relative">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
|
||||
<svg className="size-7 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>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5 border-b border-gray-100 flex items-center justify-between">
|
||||
<h2 className="text-base font-bold text-[#0f172a]">최근 발송 내역</h2>
|
||||
<Link
|
||||
to="/statistics/history"
|
||||
className="text-primary hover:text-[#1d4ed8] text-sm font-medium"
|
||||
>
|
||||
전체 보기
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="w-full text-sm text-left h-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||
템플릿명
|
||||
</th>
|
||||
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||
타겟 수
|
||||
</th>
|
||||
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||
상태
|
||||
</th>
|
||||
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||
발송 시간
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{messages.map((msg, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-3.5 font-medium text-gray-900 text-center">
|
||||
{msg.template}
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-center text-gray-600">{msg.targetCount}</td>
|
||||
<td className="px-6 py-3.5 text-center">
|
||||
<StatusBadge
|
||||
variant={STATUS_MAP[msg.status]}
|
||||
label={msg.status}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-center text-gray-500">{msg.sentAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
react/src/features/dashboard/components/StatsCards.tsx
Normal file
84
react/src/features/dashboard/components/StatsCards.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { Link } from "react-router-dom";
|
||||
|
||||
interface StatCard {
|
||||
label: string;
|
||||
value: string;
|
||||
/** 값 뒤에 붙는 단위 (예: "%") */
|
||||
unit?: string;
|
||||
/** 우상단 뱃지 */
|
||||
badge?: { type: "trend"; value: string } | { type: "icon"; icon: string; color: string };
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface StatsCardsProps {
|
||||
cards?: StatCard[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** 하드코딩 기본 데이터 */
|
||||
const DEFAULT_CARDS: StatCard[] = [
|
||||
{
|
||||
label: "오늘 발송 수",
|
||||
value: "12,847",
|
||||
badge: { type: "trend", value: "15%" },
|
||||
link: "/statistics",
|
||||
},
|
||||
{
|
||||
label: "성공률",
|
||||
value: "98.7",
|
||||
unit: "%",
|
||||
badge: { type: "icon", icon: "check_circle", color: "bg-green-100 text-green-600" },
|
||||
link: "/statistics",
|
||||
},
|
||||
{
|
||||
label: "등록 기기 수",
|
||||
value: "45,230",
|
||||
badge: { type: "icon", icon: "devices", color: "bg-blue-50 text-primary" },
|
||||
link: "/devices",
|
||||
},
|
||||
{
|
||||
label: "활성 서비스",
|
||||
value: "8",
|
||||
badge: { type: "icon", icon: "grid_view", color: "bg-purple-50 text-purple-600" },
|
||||
link: "/services",
|
||||
},
|
||||
];
|
||||
|
||||
export default function StatsCards({ cards = DEFAULT_CARDS, loading }: StatsCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
key={card.label}
|
||||
to={card.link}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 flex flex-col justify-between h-28 hover:shadow-md hover:border-primary/30 transition-all cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-medium text-gray-500">{card.label}</h4>
|
||||
{card.badge?.type === "trend" && (
|
||||
<div className="bg-green-50 text-green-700 text-[10px] font-bold px-1.5 py-0.5 rounded-full inline-flex items-center gap-0.5">
|
||||
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "10px" }}>trending_up</span>
|
||||
{card.badge.value}
|
||||
</div>
|
||||
)}
|
||||
{card.badge?.type === "icon" && (
|
||||
<div className={`${card.badge.color} rounded-full size-6 inline-flex items-center justify-center`}>
|
||||
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>{card.badge.icon}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="h-8 w-24 rounded bg-gray-100 animate-pulse" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{card.value}
|
||||
{card.unit && (
|
||||
<span className="text-base text-gray-400 ml-0.5 font-normal">{card.unit}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
300
react/src/features/dashboard/components/WeeklyChart.tsx
Normal file
300
react/src/features/dashboard/components/WeeklyChart.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { useRef, useEffect, useCallback, useState } from "react";
|
||||
|
||||
interface ChartDataPoint {
|
||||
label: string;
|
||||
/** Y축 비율 (0=최상단, 1=최하단) — 0이 최대값 */
|
||||
blue: number;
|
||||
green: number;
|
||||
sent: string;
|
||||
reach: string;
|
||||
}
|
||||
|
||||
interface WeeklyChartProps {
|
||||
data?: ChartDataPoint[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** 하드코딩 기본 데이터 (HTML 시안과 동일) */
|
||||
const DEFAULT_DATA: ChartDataPoint[] = [
|
||||
{ label: "02.09", blue: 0.75, green: 0.8, sent: "3,750", reach: "3,000" },
|
||||
{ label: "02.10", blue: 0.6, green: 0.675, sent: "6,000", reach: "4,875" },
|
||||
{ label: "02.11", blue: 0.4, green: 0.475, sent: "9,000", reach: "7,875" },
|
||||
{ label: "02.12", blue: 0.5, green: 0.575, sent: "7,500", reach: "6,375" },
|
||||
{ label: "02.13", blue: 0.2, green: 0.275, sent: "12,000", reach: "10,875" },
|
||||
{ label: "02.14", blue: 0.3, green: 0.35, sent: "10,500", reach: "9,750" },
|
||||
{ label: "Today", blue: 0.1, green: 0.175, sent: "13,500", reach: "12,375" },
|
||||
];
|
||||
|
||||
const MARGIN = 0.02;
|
||||
|
||||
export default function WeeklyChart({ data = DEFAULT_DATA, loading }: WeeklyChartProps) {
|
||||
const areaRef = useRef<HTMLDivElement>(null);
|
||||
const animatedRef = useRef(false);
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
|
||||
const count = data.length;
|
||||
const xRatios =
|
||||
count === 1
|
||||
? [MARGIN]
|
||||
: data.map((_, i) => MARGIN + (i / (count - 1)) * (1 - MARGIN * 2));
|
||||
|
||||
const drawChart = useCallback(() => {
|
||||
const area = areaRef.current;
|
||||
if (!area || count === 0) return;
|
||||
|
||||
// 기존 SVG + 점 제거
|
||||
area.querySelectorAll("svg, .chart-dot").forEach((el) => el.remove());
|
||||
|
||||
const W = area.offsetWidth;
|
||||
const H = area.offsetHeight;
|
||||
if (W === 0 || H === 0) return;
|
||||
|
||||
const toPixel = (xr: number, yr: number): [number, number] => [
|
||||
Math.round(xr * W * 10) / 10,
|
||||
Math.round(yr * H * 10) / 10,
|
||||
];
|
||||
|
||||
const bluePoints = xRatios.map((x, i) => toPixel(x, data[i].blue));
|
||||
const greenPoints = xRatios.map((x, i) => toPixel(x, data[i].green));
|
||||
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(NS, "svg");
|
||||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||
svg.style.cssText = "position:absolute;inset:0;width:100%;height:100%;";
|
||||
|
||||
function makePath(points: [number, number][], color: string) {
|
||||
if (points.length < 2) return null;
|
||||
const path = document.createElementNS(NS, "path");
|
||||
path.setAttribute(
|
||||
"d",
|
||||
points.map((p, i) => `${i ? "L" : "M"}${p[0]},${p[1]}`).join(" "),
|
||||
);
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", color);
|
||||
path.setAttribute("stroke-width", "2.5");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
svg.appendChild(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
const bluePath = makePath(bluePoints, "#2563EB");
|
||||
const greenPath = makePath(greenPoints, "#22C55E");
|
||||
area.appendChild(svg);
|
||||
|
||||
// 애니메이션 (최초 1회만)
|
||||
const DURATION = 2000;
|
||||
|
||||
function getCumulativeRatios(pts: [number, number][]) {
|
||||
const d = [0];
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const dx = pts[i][0] - pts[i - 1][0];
|
||||
const dy = pts[i][1] - pts[i - 1][1];
|
||||
d.push(d[i - 1] + Math.sqrt(dx * dx + dy * dy));
|
||||
}
|
||||
const t = d[d.length - 1];
|
||||
return t > 0 ? d.map((v) => v / t) : d.map(() => 0);
|
||||
}
|
||||
|
||||
function easeOutTime(r: number) {
|
||||
return 1 - Math.sqrt(1 - r);
|
||||
}
|
||||
|
||||
function renderLine(
|
||||
path: SVGPathElement | null,
|
||||
points: [number, number][],
|
||||
color: string,
|
||||
) {
|
||||
if (path && !animatedRef.current) {
|
||||
const len = path.getTotalLength();
|
||||
path.style.strokeDasharray = String(len);
|
||||
path.style.strokeDashoffset = String(len);
|
||||
path.getBoundingClientRect(); // 강제 리플로
|
||||
path.style.transition = `stroke-dashoffset ${DURATION}ms ease-out`;
|
||||
path.style.strokeDashoffset = "0";
|
||||
}
|
||||
|
||||
const ratios = getCumulativeRatios(points);
|
||||
points.forEach(([px, py], i) => {
|
||||
const dot = document.createElement("div");
|
||||
dot.className = "chart-dot";
|
||||
dot.style.left = px + "px";
|
||||
dot.style.top = py + "px";
|
||||
dot.style.borderColor = color;
|
||||
area!.appendChild(dot);
|
||||
|
||||
if (!animatedRef.current && path) {
|
||||
setTimeout(() => dot.classList.add("show"), easeOutTime(ratios[i]) * DURATION);
|
||||
} else {
|
||||
dot.classList.add("show");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderLine(bluePath, bluePoints, "#2563EB");
|
||||
renderLine(greenPath, greenPoints, "#22C55E");
|
||||
|
||||
animatedRef.current = true;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
/* data가 바뀌면 애니메이션 재생 */
|
||||
useEffect(() => {
|
||||
animatedRef.current = false;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
drawChart();
|
||||
|
||||
let resizeTimer: ReturnType<typeof setTimeout>;
|
||||
const handleResize = () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(drawChart, 150);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
clearTimeout(resizeTimer);
|
||||
};
|
||||
}, [drawChart]);
|
||||
|
||||
// 호버존 계산 (CSS 기반)
|
||||
const getHoverZoneStyle = (i: number): React.CSSProperties => {
|
||||
if (count <= 1) return { left: 0, width: "100%" };
|
||||
const halfGap =
|
||||
i === 0
|
||||
? ((xRatios[1] - xRatios[0]) / 2) * 100
|
||||
: i === count - 1
|
||||
? ((xRatios[count - 1] - xRatios[count - 2]) / 2) * 100
|
||||
: ((xRatios[i + 1] - xRatios[i - 1]) / 4) * 100;
|
||||
return {
|
||||
left: `${(xRatios[i] * 100 - halfGap).toFixed(2)}%`,
|
||||
width: `${(halfGap * 2).toFixed(2)}%`,
|
||||
};
|
||||
};
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-base font-bold text-[#0f172a]">최근 7일 발송 추이</h2>
|
||||
</div>
|
||||
<div className="w-full h-72 flex items-center justify-center">
|
||||
<p className="text-sm text-gray-400">발송 내역이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-base font-bold text-[#0f172a]">최근 7일 발송 추이</h2>
|
||||
<div className="flex items-center gap-4 text-xs font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-3 rounded-full bg-primary" />
|
||||
<span className="text-gray-600">발송</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-3 rounded-full bg-green-500" />
|
||||
<span className="text-gray-600">도달</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차트 영역 */}
|
||||
<div className="w-full h-72 relative">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 rounded-lg">
|
||||
<svg className="size-7 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>
|
||||
</div>
|
||||
)}
|
||||
{/* Y축 라벨 + 그리드 */}
|
||||
<div className="absolute inset-0 flex flex-col justify-between text-xs text-gray-400 pb-6 pr-4 pointer-events-none">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="w-8 text-right mr-2">15k</span>
|
||||
<div className="flex-1 h-px bg-gray-100" />
|
||||
</div>
|
||||
<div className="flex items-center w-full">
|
||||
<span className="w-8 text-right mr-2">10k</span>
|
||||
<div className="flex-1 h-px bg-gray-100" />
|
||||
</div>
|
||||
<div className="flex items-center w-full">
|
||||
<span className="w-8 text-right mr-2">5k</span>
|
||||
<div className="flex-1 h-px bg-gray-100" />
|
||||
</div>
|
||||
<div className="flex items-center w-full">
|
||||
<span className="w-8 text-right mr-2">0</span>
|
||||
<div className="flex-1 h-px bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SVG 차트 영역 */}
|
||||
<div ref={areaRef} className="absolute top-1 bottom-6 left-10 right-4">
|
||||
{/* 호버존 */}
|
||||
{data.map((d, i) => (
|
||||
<div
|
||||
key={d.label}
|
||||
className="absolute top-0 bottom-0 cursor-pointer z-[5]"
|
||||
style={getHoverZoneStyle(i)}
|
||||
onMouseEnter={() => setHoverIndex(i)}
|
||||
onMouseLeave={() => setHoverIndex(null)}
|
||||
>
|
||||
{/* 가이드라인 */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px left-1/2 bg-gray-300 pointer-events-none transition-opacity duration-150"
|
||||
style={{ opacity: hoverIndex === i ? 1 : 0 }}
|
||||
/>
|
||||
{/* 툴팁 */}
|
||||
<div
|
||||
className="absolute left-1/2 pointer-events-none z-10 transition-all duration-150"
|
||||
style={{
|
||||
top: `${Math.max(0, Math.min(d.blue, d.green) * 100 - 5)}%`,
|
||||
transform: `translateX(-50%) translateY(${hoverIndex === i ? 0 : 4}px)`,
|
||||
opacity: hoverIndex === i ? 1 : 0,
|
||||
visibility: hoverIndex === i ? "visible" : "hidden",
|
||||
}}
|
||||
>
|
||||
<div className="bg-white border border-gray-200 rounded-lg shadow-lg px-4 py-3 min-w-[140px]">
|
||||
<p className="text-xs font-bold text-gray-900 mb-2 pb-1.5 border-b border-gray-100">
|
||||
{d.label} 발송 추이
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="size-2.5 rounded-full bg-[#2563EB] flex-shrink-0" />
|
||||
<span className="text-xs text-gray-500 flex-1">발송</span>
|
||||
<span className="text-xs font-semibold text-gray-900">{d.sent}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2.5 rounded-full bg-[#22C55E] flex-shrink-0" />
|
||||
<span className="text-xs text-gray-500 flex-1">도달</span>
|
||||
<span className="text-xs font-semibold text-gray-900">{d.reach}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* X축 날짜 라벨 */}
|
||||
<div className="absolute bottom-0 left-10 right-4 h-5 text-xs text-gray-400">
|
||||
{data.map((d, i) => (
|
||||
<span
|
||||
key={d.label}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${xRatios[i] * 100}%`,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
{d.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,136 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import PageHeader from "@/components/common/PageHeader";
|
||||
import DashboardFilter from "../components/DashboardFilter";
|
||||
import StatsCards from "../components/StatsCards";
|
||||
import WeeklyChart from "../components/WeeklyChart";
|
||||
import RecentMessages from "../components/RecentMessages";
|
||||
import PlatformDonut from "../components/PlatformDonut";
|
||||
|
||||
/** 랜덤 정수 (min~max) */
|
||||
function rand(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/** 숫자를 천단위 콤마로 포맷 */
|
||||
function fmt(n: number) {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
/** 통계 카드 랜덤 데이터 생성 */
|
||||
function randomCards() {
|
||||
const sent = rand(5000, 30000);
|
||||
const rate = (95 + Math.random() * 4.9).toFixed(1);
|
||||
const devices = rand(20000, 80000);
|
||||
const services = rand(3, 15);
|
||||
const trend = rand(1, 30);
|
||||
return [
|
||||
{
|
||||
label: "오늘 발송 수",
|
||||
value: fmt(sent),
|
||||
badge: { type: "trend" as const, value: `${trend}%` },
|
||||
link: "/statistics",
|
||||
},
|
||||
{
|
||||
label: "성공률",
|
||||
value: rate,
|
||||
unit: "%",
|
||||
badge: { type: "icon" as const, icon: "check_circle", color: "bg-green-100 text-green-600" },
|
||||
link: "/statistics",
|
||||
},
|
||||
{
|
||||
label: "등록 기기 수",
|
||||
value: fmt(devices),
|
||||
badge: { type: "icon" as const, icon: "devices", color: "bg-blue-50 text-primary" },
|
||||
link: "/devices",
|
||||
},
|
||||
{
|
||||
label: "활성 서비스",
|
||||
value: String(services),
|
||||
badge: { type: "icon" as const, icon: "grid_view", color: "bg-purple-50 text-purple-600" },
|
||||
link: "/services",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 주간 차트 랜덤 데이터 생성 */
|
||||
function randomChart() {
|
||||
const now = new Date();
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - (6 - i));
|
||||
const label = i === 6 ? "Today" : `${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`;
|
||||
const blue = 0.1 + Math.random() * 0.7;
|
||||
const green = Math.min(blue + 0.05 + Math.random() * 0.1, 0.95);
|
||||
const sent = Math.round((1 - blue) * 15000);
|
||||
const reach = Math.round((1 - green) * 15000);
|
||||
return { label, blue, green, sent: fmt(sent), reach: fmt(reach) };
|
||||
});
|
||||
}
|
||||
|
||||
/** 최근 발송 내역 랜덤 데이터 생성 */
|
||||
function randomMessages() {
|
||||
const templates = [
|
||||
"가을맞이 프로모션 알림", "정기 점검 안내", "비밀번호 변경 알림",
|
||||
"신규 서비스 런칭", "야간 푸시 마케팅", "결제 완료 알림",
|
||||
"이벤트 당첨 안내", "서비스 업데이트 공지", "보안 알림",
|
||||
];
|
||||
const statuses = ["완료", "완료", "완료", "실패", "진행", "예약"] as const;
|
||||
const hours = ["09:00", "09:15", "10:30", "11:00", "14:00", "15:30", "18:00", "20:00"];
|
||||
|
||||
return Array.from({ length: 5 }, (_, i) => ({
|
||||
template: templates[rand(0, templates.length - 1)],
|
||||
targetCount: fmt(rand(1, 50000)),
|
||||
status: statuses[rand(0, statuses.length - 1)],
|
||||
sentAt: `2026-02-${String(rand(10, 27)).padStart(2, "0")} ${hours[rand(0, hours.length - 1)]}`,
|
||||
})).sort((a, b) => (a.sentAt > b.sentAt ? -1 : 1));
|
||||
}
|
||||
|
||||
/** 플랫폼 비율 랜덤 데이터 생성 */
|
||||
function randomPlatform() {
|
||||
const ios = rand(25, 75);
|
||||
return { ios, android: 100 - ios };
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cards, setCards] = useState<ReturnType<typeof randomCards> | undefined>();
|
||||
const [chart, setChart] = useState<ReturnType<typeof randomChart> | undefined>();
|
||||
const [messages, setMessages] = useState<ReturnType<typeof randomMessages> | undefined>();
|
||||
const [platform, setPlatform] = useState<ReturnType<typeof randomPlatform> | undefined>();
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setCards(randomCards());
|
||||
setChart(randomChart());
|
||||
setMessages(randomMessages());
|
||||
setPlatform(randomPlatform());
|
||||
setLoading(false);
|
||||
}, 1200);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">대시보드</h1>
|
||||
<>
|
||||
{/* 페이지 헤더 */}
|
||||
<PageHeader
|
||||
title="대시보드"
|
||||
description="서비스 발송 현황과 주요 지표를 한눈에 확인할 수 있습니다."
|
||||
/>
|
||||
|
||||
{/* 필터 */}
|
||||
<DashboardFilter onSearch={handleSearch} loading={loading} />
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<StatsCards cards={cards} loading={loading} />
|
||||
|
||||
{/* 7일 발송 추이 차트 */}
|
||||
<WeeklyChart data={chart} loading={loading} />
|
||||
|
||||
{/* 최근 발송 내역 + 플랫폼 도넛 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<RecentMessages messages={messages} loading={loading} />
|
||||
<PlatformDonut data={platform} loading={loading} />
|
||||
</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