diff --git a/react/src/components/common/CategoryBadge.tsx b/react/src/components/common/CategoryBadge.tsx new file mode 100644 index 0000000..b438257 --- /dev/null +++ b/react/src/components/common/CategoryBadge.tsx @@ -0,0 +1,27 @@ +type CategoryVariant = "success" | "warning" | "error" | "info" | "default"; + +interface CategoryBadgeProps { + variant: CategoryVariant; + icon: string; + label: string; +} + +const VARIANT_STYLES: Record = { + 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 ( + + {icon} + {label} + + ); +} diff --git a/react/src/components/common/CopyButton.tsx b/react/src/components/common/CopyButton.tsx new file mode 100644 index 0000000..5a480c1 --- /dev/null +++ b/react/src/components/common/CopyButton.tsx @@ -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 ( + + ); +} diff --git a/react/src/components/common/DateRangeInput.tsx b/react/src/components/common/DateRangeInput.tsx new file mode 100644 index 0000000..a965b7f --- /dev/null +++ b/react/src/components/common/DateRangeInput.tsx @@ -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 ( + <> + {/* 시작일 */} +
+ +
+ + calendar_today + + 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" + /> +
+
+ + ~ + + {/* 종료일 */} +
+ +
+ + calendar_today + + 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" + /> +
+
+ + ); +} diff --git a/react/src/components/common/EmptyState.tsx b/react/src/components/common/EmptyState.tsx new file mode 100644 index 0000000..f6623d8 --- /dev/null +++ b/react/src/components/common/EmptyState.tsx @@ -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 ( +
+ + {icon} + +

{message}

+ {description && ( +

{description}

+ )} + {action &&
{action}
} +
+ ); +} diff --git a/react/src/components/common/FilterDropdown.tsx b/react/src/components/common/FilterDropdown.tsx new file mode 100644 index 0000000..82ecbd5 --- /dev/null +++ b/react/src/components/common/FilterDropdown.tsx @@ -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(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 ( +
+ {label && ( + + )} + + {open && ( +
    + {options.map((opt) => ( +
  • { + 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} +
  • + ))} +
+ )} +
+ ); +} diff --git a/react/src/components/common/FilterResetButton.tsx b/react/src/components/common/FilterResetButton.tsx new file mode 100644 index 0000000..312106a --- /dev/null +++ b/react/src/components/common/FilterResetButton.tsx @@ -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 ( + + ); +} diff --git a/react/src/components/common/PageHeader.tsx b/react/src/components/common/PageHeader.tsx new file mode 100644 index 0000000..b636ae7 --- /dev/null +++ b/react/src/components/common/PageHeader.tsx @@ -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 ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {action &&
{action}
} +
+ ); +} diff --git a/react/src/components/common/Pagination.tsx b/react/src/components/common/Pagination.tsx new file mode 100644 index 0000000..37ac93d --- /dev/null +++ b/react/src/components/common/Pagination.tsx @@ -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 ( +
+ + 총 {totalItems.toLocaleString()}건 중 {start.toLocaleString()}-{end.toLocaleString()} 표시 + + +
+ {/* 처음 */} + + + {/* 이전 */} + + + {/* 페이지 번호 */} + {getPageNumbers().map((page) => ( + + ))} + + {/* 다음 */} + + + {/* 마지막 */} + +
+
+ ); +} diff --git a/react/src/components/common/PlatformBadge.tsx b/react/src/components/common/PlatformBadge.tsx new file mode 100644 index 0000000..464ac56 --- /dev/null +++ b/react/src/components/common/PlatformBadge.tsx @@ -0,0 +1,29 @@ +interface PlatformBadgeProps { + platform: "ios" | "android"; +} + +/** iOS/Android 아이콘 뱃지 */ +export default function PlatformBadge({ platform }: PlatformBadgeProps) { + if (platform === "ios") { + return ( + + + + + iOS + + ); + } + + return ( + + + android + + Android + + ); +} diff --git a/react/src/components/common/SearchInput.tsx b/react/src/components/common/SearchInput.tsx new file mode 100644 index 0000000..267154c --- /dev/null +++ b/react/src/components/common/SearchInput.tsx @@ -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 ( +
+ {label && ( + + )} +
+ + search + + 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" + /> +
+
+ ); +} diff --git a/react/src/components/common/StatusBadge.tsx b/react/src/components/common/StatusBadge.tsx new file mode 100644 index 0000000..d429520 --- /dev/null +++ b/react/src/components/common/StatusBadge.tsx @@ -0,0 +1,43 @@ +type StatusVariant = "success" | "error" | "warning" | "info" | "default"; + +interface StatusBadgeProps { + variant: StatusVariant; + label: string; +} + +const VARIANT_STYLES: Record = { + 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 ( + + + {label} + + ); +} diff --git a/react/src/components/layout/AppHeader.tsx b/react/src/components/layout/AppHeader.tsx index 028d295..2749154 100644 --- a/react/src/components/layout/AppHeader.tsx +++ b/react/src/components/layout/AppHeader.tsx @@ -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 = { - "/": "Home", "/dashboard": "대시보드", "/services": "서비스 관리", "/services/register": "서비스 등록", @@ -17,35 +18,138 @@ const pathLabels: Record = { "/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(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 (
- {/* 브레드크럼 */} -