feat: 설정 페이지 기능 개선 및 알림 상세 패널 구현
All checks were successful
SPMS_BO/pipeline/head This commit looks good
All checks were successful
SPMS_BO/pipeline/head This commit looks good
- 알림 상세 슬라이드 패널 (NotificationSlidePanel) 신규 생성 - 헤더 알림 드롭다운에서 클릭 시 알림 페이지 이동 + 패널 자동 오픈 - 프로필 수정 필수값 검증: useShake + 인라인 에러 메시지 패턴 적용 - 사이드바 프로필 아이콘 클릭 시 마이페이지 이동 - 사이드바 메뉴 그룹 경로 변경 시 자동 접힘 처리 - 대시보드, 마이페이지 UI 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c3073f0e87
commit
ca88d5ba08
|
|
@ -1,6 +1,10 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import CategoryBadge from "@/components/common/CategoryBadge";
|
||||
import {
|
||||
MOCK_NOTIFICATIONS as SHARED_NOTIFICATIONS,
|
||||
type NotificationType,
|
||||
} from "@/features/settings/types";
|
||||
|
||||
/** 경로 → breadcrumb 레이블 매핑 (정적 경로) */
|
||||
const pathLabels: Record<string, string> = {
|
||||
|
|
@ -81,26 +85,24 @@ function buildBreadcrumbs(pathname: string) {
|
|||
return crumbs;
|
||||
}
|
||||
|
||||
/* ── 알림 더미 데이터 ── */
|
||||
interface NotificationItem {
|
||||
variant: "success" | "warning" | "error" | "info" | "default";
|
||||
icon: string;
|
||||
category: string;
|
||||
title: string;
|
||||
description: string;
|
||||
time: string;
|
||||
}
|
||||
/* ── NotificationType → CategoryBadge 매핑 ── */
|
||||
const NOTI_BADGE_MAP: Record<
|
||||
NotificationType,
|
||||
{ variant: "success" | "warning" | "error" | "info" | "default"; icon: string }
|
||||
> = {
|
||||
"발송": { variant: "success", icon: "check_circle" },
|
||||
"인증서": { variant: "warning", icon: "verified" },
|
||||
"실패": { variant: "error", icon: "error" },
|
||||
"서비스": { variant: "info", icon: "cloud" },
|
||||
"시스템": { variant: "default", icon: "settings" },
|
||||
};
|
||||
|
||||
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일 전" },
|
||||
];
|
||||
/** 헤더 드롭다운에 표시할 최근 알림 (최대 5개) */
|
||||
const HEADER_NOTIFICATIONS = SHARED_NOTIFICATIONS.slice(0, 5);
|
||||
|
||||
export default function AppHeader() {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const crumbs = buildBreadcrumbs(pathname);
|
||||
const [notiOpen, setNotiOpen] = useState(false);
|
||||
const notiRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -179,19 +181,32 @@ export default function AppHeader() {
|
|||
|
||||
{/* 알림 목록 */}
|
||||
<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>
|
||||
{HEADER_NOTIFICATIONS.map((noti) => {
|
||||
const badge = NOTI_BADGE_MAP[noti.type];
|
||||
return (
|
||||
<div
|
||||
key={noti.id}
|
||||
onClick={() => {
|
||||
setNotiOpen(false);
|
||||
navigate("/settings/notifications", {
|
||||
state: { notificationId: noti.id },
|
||||
});
|
||||
}}
|
||||
className={`cursor-pointer border-b border-gray-50 px-4 py-3 transition-colors hover:bg-gray-50 ${
|
||||
!noti.read ? "bg-blue-50/20" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<CategoryBadge variant={badge.variant} icon={badge.icon} label={noti.type} />
|
||||
<p className={`flex-1 truncate text-xs text-foreground ${!noti.read ? "font-bold" : "font-medium"}`}>
|
||||
{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>
|
||||
<p className="truncate text-[11px] text-gray-500">{noti.description}</p>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
|
|
@ -83,6 +83,11 @@ function SidebarGroup({ group }: { group: NavGroup }) {
|
|||
);
|
||||
const [isOpen, setIsOpen] = useState(isChildActive);
|
||||
|
||||
// 경로 변경 시: 해당 그룹 자식이면 열고, 아니면 닫기
|
||||
useEffect(() => {
|
||||
setIsOpen(isChildActive);
|
||||
}, [isChildActive]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
|
|
@ -168,9 +173,13 @@ export default function AppSidebar() {
|
|||
{/* 사용자 프로필 */}
|
||||
<div className="border-t border-gray-800 p-4">
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<div className="flex size-9 items-center justify-center rounded-full bg-blue-600 text-xs font-bold tracking-wide text-white shadow-sm">
|
||||
<Link
|
||||
to="/settings"
|
||||
className="flex size-9 items-center justify-center rounded-full bg-blue-600 text-xs font-bold tracking-wide text-white shadow-sm hover:bg-blue-500 transition-colors"
|
||||
title="마이 페이지"
|
||||
>
|
||||
{user?.name?.slice(0, 2)?.toUpperCase() ?? "AU"}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex-1 overflow-hidden" style={{ containerType: "inline-size" }}>
|
||||
<p className="truncate text-sm font-medium text-white">
|
||||
{user?.name ?? "Admin User"}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,109 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
to: "/dashboard",
|
||||
icon: "dashboard",
|
||||
label: "대시보드",
|
||||
desc: "발송 현황 한눈에 보기",
|
||||
color: "blue" as const,
|
||||
},
|
||||
{
|
||||
to: "/services",
|
||||
icon: "manage_accounts",
|
||||
label: "서비스 관리",
|
||||
desc: "서비스 목록 및 설정",
|
||||
color: "blue" as const,
|
||||
},
|
||||
{
|
||||
to: "/services/register",
|
||||
icon: "add_circle",
|
||||
label: "서비스 등록",
|
||||
desc: "새 서비스 등록하기",
|
||||
color: "green" as const,
|
||||
},
|
||||
{
|
||||
to: "/statistics/history",
|
||||
icon: "send",
|
||||
label: "발송 내역",
|
||||
desc: "최근 메시지 발송 기록",
|
||||
color: "blue" as const,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function HomePage() {
|
||||
const userName = useAuthStore((s) => s.user?.name) ?? "관리자";
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">홈</h1>
|
||||
<div className="flex-1 flex items-center justify-center pt-16">
|
||||
<div className="text-center max-w-lg px-8">
|
||||
{/* 아이콘 */}
|
||||
<div className="mx-auto mb-8 size-20 rounded-2xl bg-primary/10 flex items-center justify-center border border-primary/20">
|
||||
<span
|
||||
className="material-symbols-outlined text-primary"
|
||||
style={{ fontSize: "50px" }}
|
||||
>
|
||||
grid_view
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 인사말 */}
|
||||
<h1 className="text-3xl font-bold text-foreground tracking-tight mb-3">
|
||||
안녕하세요, {userName}님
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-base leading-relaxed mb-10">
|
||||
SPMS Admin Console에 오신 것을 환영합니다.
|
||||
<br />
|
||||
아래 바로가기를 통해 원하는 메뉴로 이동하세요.
|
||||
</p>
|
||||
|
||||
{/* 바로가기 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{shortcuts.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="group flex items-center gap-3 p-4 bg-white border border-gray-200 rounded-xl hover:border-primary/30 hover:shadow-md transition-all"
|
||||
>
|
||||
<div
|
||||
className={`size-10 rounded-lg flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
item.color === "green"
|
||||
? "bg-green-50 group-hover:bg-green-100"
|
||||
: "bg-blue-50 group-hover:bg-primary/10"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-xl ${
|
||||
item.color === "green" ? "text-green-600" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p
|
||||
className={`text-sm font-semibold text-foreground transition-colors ${
|
||||
item.color === "green"
|
||||
? "group-hover:text-green-600"
|
||||
: "group-hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{item.desc}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 버전 */}
|
||||
<p className="text-xs text-gray-400 mt-10">
|
||||
Stein Push Messaging Service v1.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import type { Notification, NotificationType } from "../types";
|
||||
|
||||
interface NotificationSlidePanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
notification: Notification | null;
|
||||
onMarkRead: (id: number) => void;
|
||||
}
|
||||
|
||||
/** 타입별 배지 스타일 */
|
||||
const BADGE_STYLE: Record<
|
||||
NotificationType,
|
||||
{ bg: string; text: string; icon: string }
|
||||
> = {
|
||||
"발송": { bg: "bg-green-50", text: "text-green-700", icon: "check_circle" },
|
||||
"인증서": { bg: "bg-amber-50", text: "text-amber-700", icon: "warning" },
|
||||
"서비스": { bg: "bg-blue-50", text: "text-blue-700", icon: "add_circle" },
|
||||
"실패": { bg: "bg-red-50", text: "text-red-600", icon: "error" },
|
||||
"시스템": { bg: "bg-gray-100", text: "text-gray-600", icon: "settings" },
|
||||
};
|
||||
|
||||
/** 알림 상세 슬라이드 패널 */
|
||||
export default function NotificationSlidePanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
notification,
|
||||
onMarkRead,
|
||||
}: NotificationSlidePanelProps) {
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 패널 열릴 때 스크롤 최상단 리셋
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
bodyRef.current?.scrollTo(0, 0);
|
||||
}
|
||||
}, [isOpen, notification]);
|
||||
|
||||
// ESC 닫기
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isOpen) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// body 스크롤 잠금
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const badge = notification ? BADGE_STYLE[notification.type] : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 오버레이 */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
|
||||
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 패널 */}
|
||||
<aside
|
||||
className={`fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 flex flex-col transition-transform duration-300 ${
|
||||
isOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 flex-shrink-0">
|
||||
<h2 className="text-lg font-bold text-gray-900">알림 상세</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="size-8 flex items-center justify-center text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className="flex-1 overflow-y-auto p-6 space-y-6"
|
||||
style={{ overscrollBehavior: "contain" }}
|
||||
>
|
||||
{notification && badge ? (
|
||||
<>
|
||||
{/* 타입 배지 + 시간 + 날짜 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded ${badge.bg} ${badge.text} text-xs font-semibold`}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined"
|
||||
style={{ fontSize: "13px" }}
|
||||
>
|
||||
{badge.icon}
|
||||
</span>
|
||||
{notification.type}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{notification.date} {notification.time}
|
||||
</span>
|
||||
{/* 읽음 상태 */}
|
||||
{notification.read ? (
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-xs text-gray-400">
|
||||
<span
|
||||
className="material-symbols-outlined"
|
||||
style={{ fontSize: "14px" }}
|
||||
>
|
||||
done_all
|
||||
</span>
|
||||
읽음
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-xs text-[#2563EB]">
|
||||
<span className="size-2 rounded-full bg-[#2563EB]" />
|
||||
미읽음
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{notification.title}
|
||||
</h3>
|
||||
|
||||
{/* 설명 (전체 표시) */}
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{notification.description}
|
||||
</p>
|
||||
|
||||
{/* 상세 내용 */}
|
||||
{notification.detail && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||
상세 내용
|
||||
</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<pre className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap font-sans">
|
||||
{notification.detail}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||
알림을 선택해주세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex-shrink-0 px-6 py-4 border-t border-gray-200 flex gap-3">
|
||||
{notification && !notification.read && (
|
||||
<button
|
||||
onClick={() => onMarkRead(notification.id)}
|
||||
className="flex-1 px-4 py-2.5 bg-[#2563EB] text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
읽음 처리
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`px-4 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-50 transition-colors ${
|
||||
notification && !notification.read ? "" : "w-full"
|
||||
}`}
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,216 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import PageHeader from "@/components/common/PageHeader";
|
||||
import Pagination from "@/components/common/Pagination";
|
||||
import {
|
||||
MOCK_PROFILE,
|
||||
MOCK_ACTIVITIES,
|
||||
type Activity,
|
||||
} from "../types";
|
||||
|
||||
// 활동 타입별 아이콘 + 색상 매핑
|
||||
const ACTIVITY_STYLE: Record<
|
||||
Activity["type"],
|
||||
{ icon: string; bg: string; text: string }
|
||||
> = {
|
||||
send: { icon: "send", bg: "bg-green-50", text: "text-green-600" },
|
||||
service: { icon: "cloud", bg: "bg-blue-50", text: "text-blue-600" },
|
||||
device: { icon: "verified", bg: "bg-amber-50", text: "text-amber-600" },
|
||||
auth: { icon: "login", bg: "bg-gray-50", text: "text-gray-500" },
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
export default function MyPage() {
|
||||
const navigate = useNavigate();
|
||||
const profile = MOCK_PROFILE;
|
||||
const initials = profile.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
// 최근 활동 페이지네이션
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const totalItems = MOCK_ACTIVITIES.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
|
||||
const pagedActivities = useMemo(
|
||||
() =>
|
||||
MOCK_ACTIVITIES.slice(
|
||||
(currentPage - 1) * PAGE_SIZE,
|
||||
currentPage * PAGE_SIZE,
|
||||
),
|
||||
[currentPage],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">마이페이지</h1>
|
||||
</div>
|
||||
<>
|
||||
<PageHeader
|
||||
title="마이페이지"
|
||||
description="내 계정 정보와 활동 내역을 확인하고 관리할 수 있습니다"
|
||||
action={
|
||||
<button
|
||||
onClick={() => navigate("/settings/profile")}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors shadow-sm"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">edit</span>
|
||||
프로필 수정
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 프로필 카드 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
|
||||
<div className="p-8">
|
||||
<div className="flex items-center gap-6">
|
||||
{/* 아바타 */}
|
||||
<div className="size-20 rounded-full bg-[#2563EB] flex items-center justify-center text-white text-2xl font-bold tracking-wide shadow-md flex-shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
{/* 기본 정보 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h2 className="text-xl font-bold text-[#0f172a]">
|
||||
{profile.name}
|
||||
</h2>
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-[#2563EB]/10 text-[#2563EB] border border-[#2563EB]/20">
|
||||
<span
|
||||
className="material-symbols-outlined"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
shield
|
||||
</span>
|
||||
{profile.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[#64748b]">{profile.email}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
마지막 로그인: {profile.lastLogin} (KST)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2열 그리드 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{/* 계정 정보 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||
person
|
||||
</span>
|
||||
계정 정보
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{[
|
||||
{ label: "소속", value: profile.team },
|
||||
{ label: "연락처", value: profile.phone },
|
||||
{ label: "가입일", value: profile.joinDate },
|
||||
{ label: "최근 로그인", value: profile.lastLogin },
|
||||
].map((item, i, arr) => (
|
||||
<div key={item.label}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[#64748b]">{item.label}</span>
|
||||
<span className="text-sm font-medium text-[#0f172a]">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
{i < arr.length - 1 && (
|
||||
<div className="h-px bg-gray-100 mt-4" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 보안 설정 (추후 개발) */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||
security
|
||||
</span>
|
||||
보안 설정
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2.5 py-1 rounded-full">
|
||||
추후 개발 예정
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 활동 내역 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||
history
|
||||
</span>
|
||||
최근 활동 내역
|
||||
</h3>
|
||||
<span className="text-xs text-[#64748b]">최근 7일</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{pagedActivities.map((activity) => {
|
||||
const style = ACTIVITY_STYLE[activity.type];
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-center gap-4 px-6 py-4 hover:bg-gray-50/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`size-9 rounded-full ${style.bg} flex items-center justify-center flex-shrink-0`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined ${style.text} text-lg`}
|
||||
>
|
||||
{style.icon}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[#0f172a] truncate">
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{activity.detail}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{activity.time}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
<div className="border-t border-gray-100">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 접속 기기 (추후 개발) */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||
devices
|
||||
</span>
|
||||
접속 기기
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2.5 py-1 rounded-full">
|
||||
추후 개발 예정
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,248 @@
|
|||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import PageHeader from "@/components/common/PageHeader";
|
||||
import { MOCK_NOTIFICATIONS, type Notification, type NotificationType } from "../types";
|
||||
import NotificationSlidePanel from "../components/NotificationSlidePanel";
|
||||
|
||||
// 타입별 배지 스타일
|
||||
const BADGE_STYLE: Record<
|
||||
NotificationType,
|
||||
{ bg: string; text: string; icon: string }
|
||||
> = {
|
||||
"발송": { bg: "bg-green-50", text: "text-green-700", icon: "check_circle" },
|
||||
"인증서": { bg: "bg-amber-50", text: "text-amber-700", icon: "warning" },
|
||||
"서비스": { bg: "bg-blue-50", text: "text-blue-700", icon: "add_circle" },
|
||||
"실패": { bg: "bg-red-50", text: "text-red-600", icon: "error" },
|
||||
"시스템": { bg: "bg-gray-100", text: "text-gray-600", icon: "settings" },
|
||||
};
|
||||
|
||||
// 날짜 그룹 레이블 → 표시 날짜
|
||||
const DATE_LABELS: Record<string, string> = {
|
||||
"오늘": "2026-02-19",
|
||||
"어제": "2026-02-18",
|
||||
"이번 주": "2026.02.16 ~ 02.17",
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [notifications, setNotifications] = useState<Notification[]>(
|
||||
() => [...MOCK_NOTIFICATIONS],
|
||||
);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedNotification, setSelectedNotification] =
|
||||
useState<Notification | null>(null);
|
||||
|
||||
// 헤더 드롭다운에서 넘어온 경우 해당 알림 패널 자동 오픈
|
||||
useEffect(() => {
|
||||
const state = location.state as { notificationId?: number } | null;
|
||||
if (state?.notificationId) {
|
||||
const target = notifications.find((n) => n.id === state.notificationId);
|
||||
if (target) {
|
||||
setSelectedNotification(target);
|
||||
// 읽음 처리
|
||||
if (!target.read) {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) =>
|
||||
n.id === target.id ? { ...n, read: true } : n,
|
||||
),
|
||||
);
|
||||
setSelectedNotification({ ...target, read: true });
|
||||
}
|
||||
}
|
||||
// state 소비 후 제거 (새로고침 시 재트리거 방지)
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}
|
||||
}, [location.state]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 페이지네이션
|
||||
const totalItems = notifications.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
|
||||
const pagedNotifications = useMemo(
|
||||
() =>
|
||||
notifications.slice(
|
||||
(currentPage - 1) * PAGE_SIZE,
|
||||
currentPage * PAGE_SIZE,
|
||||
),
|
||||
[notifications, currentPage],
|
||||
);
|
||||
|
||||
// 페이징된 알림을 날짜별로 그룹핑
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, Notification[]>();
|
||||
for (const n of pagedNotifications) {
|
||||
const arr = map.get(n.date) ?? [];
|
||||
arr.push(n);
|
||||
map.set(n.date, arr);
|
||||
}
|
||||
return map;
|
||||
}, [pagedNotifications]);
|
||||
|
||||
// 전체 읽음
|
||||
const handleReadAll = useCallback(() => {
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
toast.success("모든 알림을 읽음 처리했습니다");
|
||||
}, []);
|
||||
|
||||
// 개별 읽음
|
||||
const handleReadOne = useCallback((id: number) => {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
|
||||
);
|
||||
setSelectedNotification((prev) =>
|
||||
prev?.id === id ? { ...prev, read: true } : prev,
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">알림 설정</h1>
|
||||
</div>
|
||||
<>
|
||||
<PageHeader
|
||||
title="알림"
|
||||
description="시스템 알림과 발송 결과를 확인할 수 있습니다"
|
||||
action={
|
||||
<button
|
||||
onClick={handleReadAll}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors shadow-sm"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">
|
||||
done_all
|
||||
</span>
|
||||
전체 읽음
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 날짜별 그룹 */}
|
||||
{Array.from(grouped.entries()).map(([dateLabel, items]) => (
|
||||
<div key={dateLabel} className="mb-8">
|
||||
{/* 그룹 헤더 */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h2 className="text-xs font-bold text-[#64748b] uppercase tracking-wider">
|
||||
{dateLabel}
|
||||
</h2>
|
||||
<div className="flex-1 h-px bg-gray-200" />
|
||||
<span className="text-xs text-gray-400">
|
||||
{DATE_LABELS[dateLabel] ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 알림 카드 목록 */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||
{items.map((notification, idx) => {
|
||||
const badge = BADGE_STYLE[notification.type];
|
||||
const isUnread = !notification.read;
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
onClick={() => {
|
||||
setSelectedNotification(notification);
|
||||
handleReadOne(notification.id);
|
||||
}}
|
||||
className={`flex items-start gap-4 px-5 py-4 cursor-pointer transition-colors ${
|
||||
idx < items.length - 1
|
||||
? "border-b border-gray-100"
|
||||
: ""
|
||||
} ${
|
||||
isUnread
|
||||
? "bg-blue-50/20 hover:bg-blue-50/30"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{/* 읽음 상태 점 */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<div
|
||||
className={`size-2.5 rounded-full ${
|
||||
isUnread ? "bg-[#2563EB]" : "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{/* 콘텐츠 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded ${badge.bg} ${badge.text} text-[10px] font-semibold`}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined"
|
||||
style={{ fontSize: "11px" }}
|
||||
>
|
||||
{badge.icon}
|
||||
</span>
|
||||
{notification.type}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{notification.time}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={`text-sm truncate ${
|
||||
isUnread
|
||||
? "font-semibold text-[#0f172a]"
|
||||
: "font-medium text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs truncate mt-0.5 ${
|
||||
isUnread ? "text-gray-500" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{notification.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-center gap-1 mt-10">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="size-9 flex items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl">
|
||||
chevron_left
|
||||
</span>
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`size-9 flex items-center justify-center rounded-lg text-sm font-medium transition-colors ${
|
||||
page === currentPage
|
||||
? "bg-[#2563EB] text-white"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="size-9 flex items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl">
|
||||
chevron_right
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 알림 상세 패널 */}
|
||||
<NotificationSlidePanel
|
||||
isOpen={selectedNotification !== null}
|
||||
onClose={() => setSelectedNotification(null)}
|
||||
notification={selectedNotification}
|
||||
onMarkRead={handleReadOne}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,584 @@
|
|||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import PageHeader from "@/components/common/PageHeader";
|
||||
import useShake from "@/hooks/useShake";
|
||||
import { MOCK_PROFILE } from "../types";
|
||||
|
||||
// 비밀번호 규칙 정의
|
||||
const PASSWORD_RULES = [
|
||||
{ key: "length", label: "8자 이상 입력", test: (pw: string) => pw.length >= 8 },
|
||||
{ key: "upper", label: "영문 대문자 포함", test: (pw: string) => /[A-Z]/.test(pw) },
|
||||
{ key: "lower", label: "영문 소문자 포함", test: (pw: string) => /[a-z]/.test(pw) },
|
||||
{ key: "number", label: "숫자 포함", test: (pw: string) => /\d/.test(pw) },
|
||||
{ key: "special", label: "특수문자 포함", test: (pw: string) => /[^a-zA-Z0-9]/.test(pw) },
|
||||
] as const;
|
||||
|
||||
// 강도 레벨 산정
|
||||
function getStrengthLevel(pw: string): number {
|
||||
if (!pw) return 0;
|
||||
const checks = PASSWORD_RULES.filter((r) => r.test(pw)).length;
|
||||
if (checks <= 2) return 1;
|
||||
if (checks <= 4) return 2;
|
||||
if (checks === 5 && pw.length >= 12) return 4;
|
||||
if (checks === 5) return 3;
|
||||
return 2;
|
||||
}
|
||||
|
||||
const STRENGTH_CONFIG = [
|
||||
{ label: "", color: "" },
|
||||
{ label: "매우 약함", color: "bg-red-500 text-red-500" },
|
||||
{ label: "약함", color: "bg-amber-500 text-amber-500" },
|
||||
{ label: "보통", color: "bg-blue-500 text-blue-500" },
|
||||
{ label: "강함", color: "bg-green-500 text-green-500" },
|
||||
];
|
||||
|
||||
const STRENGTH_ICONS = ["", "error", "warning", "info", "check_circle"];
|
||||
|
||||
export default function ProfileEditPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 검증
|
||||
const [nameError, setNameError] = useState(false);
|
||||
const { triggerShake, cls } = useShake();
|
||||
|
||||
// 기본 정보 폼
|
||||
const [name, setName] = useState(MOCK_PROFILE.name);
|
||||
const [phone, setPhone] = useState(MOCK_PROFILE.phone);
|
||||
const [team, setTeam] = useState(MOCK_PROFILE.team);
|
||||
|
||||
// 비밀번호 섹션
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||
const [currentPw, setCurrentPw] = useState("");
|
||||
const [newPw, setNewPw] = useState("");
|
||||
const [confirmPw, setConfirmPw] = useState("");
|
||||
const [showCurrentPw, setShowCurrentPw] = useState(false);
|
||||
const [showNewPw, setShowNewPw] = useState(false);
|
||||
const [showConfirmPw, setShowConfirmPw] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [showPwModal, setShowPwModal] = useState(false);
|
||||
|
||||
// 비밀번호 규칙 통과 여부
|
||||
const ruleResults = useMemo(
|
||||
() => PASSWORD_RULES.map((r) => ({ ...r, pass: newPw ? r.test(newPw) : false })),
|
||||
[newPw],
|
||||
);
|
||||
const strengthLevel = useMemo(() => getStrengthLevel(newPw), [newPw]);
|
||||
const allRulesPass = ruleResults.every((r) => r.pass);
|
||||
const passwordMatch = confirmPw.length > 0 && newPw === confirmPw;
|
||||
const passwordMismatch = confirmPw.length > 0 && newPw !== confirmPw;
|
||||
|
||||
// 비밀번호 토글
|
||||
const handleTogglePasswordForm = useCallback(() => {
|
||||
if (showPasswordForm) {
|
||||
// 닫기: 입력 초기화
|
||||
setCurrentPw("");
|
||||
setNewPw("");
|
||||
setConfirmPw("");
|
||||
setShowCurrentPw(false);
|
||||
setShowNewPw(false);
|
||||
setShowConfirmPw(false);
|
||||
}
|
||||
setShowPasswordForm((prev) => !prev);
|
||||
}, [showPasswordForm]);
|
||||
|
||||
// 저장 확인
|
||||
const handleSaveConfirm = useCallback(() => {
|
||||
setShowSaveModal(false);
|
||||
toast.success("변경사항이 저장되었습니다");
|
||||
navigate("/settings");
|
||||
}, [navigate]);
|
||||
|
||||
// 비밀번호 변경 확인
|
||||
const handlePwConfirm = useCallback(() => {
|
||||
setShowPwModal(false);
|
||||
toast.success("비밀번호가 변경되었습니다");
|
||||
navigate("/settings");
|
||||
}, [navigate]);
|
||||
|
||||
// 초기화
|
||||
const handleReset = useCallback(() => {
|
||||
setName(MOCK_PROFILE.name);
|
||||
setPhone(MOCK_PROFILE.phone);
|
||||
setTeam(MOCK_PROFILE.team);
|
||||
setNameError(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">프로필 수정</h1>
|
||||
</div>
|
||||
<>
|
||||
<PageHeader
|
||||
title="프로필 수정"
|
||||
description="계정 정보와 보안 설정을 수정할 수 있습니다"
|
||||
/>
|
||||
|
||||
{/* 기본 정보 카드 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||
person
|
||||
</span>
|
||||
기본 정보
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 이름 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||
이름 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (e.target.value.trim()) setNameError(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2.5 border rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 transition-colors placeholder:text-gray-300 ${
|
||||
nameError
|
||||
? "border-red-500 ring-2 ring-red-500/15 focus:ring-red-500/20 focus:border-red-500"
|
||||
: "border-gray-200 focus:ring-[#2563EB]/20 focus:border-[#2563EB]"
|
||||
} ${cls("name")}`}
|
||||
placeholder="이름을 입력해주세요"
|
||||
/>
|
||||
{nameError && (
|
||||
<div className="flex items-center gap-1.5 text-red-600 text-xs mt-1.5">
|
||||
<span
|
||||
className="material-symbols-outlined flex-shrink-0"
|
||||
style={{ fontSize: "14px" }}
|
||||
>
|
||||
error
|
||||
</span>
|
||||
<span>이름을 입력해주세요.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 이메일 (읽기전용) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||
이메일 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={MOCK_PROFILE.email}
|
||||
readOnly
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm text-[#0f172a] bg-gray-50 cursor-not-allowed focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 text-gray-500 text-xs mt-1">
|
||||
<span
|
||||
className="material-symbols-outlined flex-shrink-0"
|
||||
style={{ fontSize: "14px" }}
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<span>이메일은 관리자만 변경할 수 있습니다</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 연락처 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||
연락처
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
|
||||
placeholder="010-0000-0000"
|
||||
/>
|
||||
</div>
|
||||
{/* 소속 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||
소속
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={team}
|
||||
onChange={(e) => setTeam(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
|
||||
placeholder="소속을 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 변경 카드 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||
lock
|
||||
</span>
|
||||
비밀번호 변경
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleTogglePasswordForm}
|
||||
className="flex items-center gap-1 text-xs font-medium text-[#2563EB] hover:text-[#1d4ed8] transition-colors"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined"
|
||||
style={{ fontSize: "14px" }}
|
||||
>
|
||||
{showPasswordForm ? "close" : "edit"}
|
||||
</span>
|
||||
{showPasswordForm ? "취소" : "비밀번호 변경하기"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPasswordForm ? (
|
||||
<div className="p-6">
|
||||
<div className="max-w-md space-y-5">
|
||||
{/* 현재 비밀번호 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||
현재 비밀번호 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrentPw ? "text" : "password"}
|
||||
value={currentPw}
|
||||
onChange={(e) => setCurrentPw(e.target.value)}
|
||||
className="w-full px-4 py-2.5 pr-10 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
|
||||
placeholder="현재 비밀번호를 입력해주세요"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrentPw((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-[#0f172a] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">
|
||||
{showCurrentPw ? "visibility" : "visibility_off"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 새 비밀번호 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||
새 비밀번호 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNewPw ? "text" : "password"}
|
||||
value={newPw}
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
className="w-full px-4 py-2.5 pr-10 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
|
||||
placeholder="새 비밀번호를 입력해주세요"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPw((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-[#0f172a] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">
|
||||
{showNewPw ? "visibility" : "visibility_off"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 규칙 */}
|
||||
{newPw && (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{ruleResults
|
||||
.filter((r) => !r.pass)
|
||||
.map((rule) => (
|
||||
<div
|
||||
key={rule.key}
|
||||
className="flex items-center gap-1.5 text-gray-500 text-xs"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined flex-shrink-0"
|
||||
style={{ fontSize: "14px" }}
|
||||
>
|
||||
keyboard
|
||||
</span>
|
||||
<span>{rule.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{ruleResults
|
||||
.filter((r) => r.pass)
|
||||
.map((rule) => (
|
||||
<div
|
||||
key={rule.key}
|
||||
className="flex items-center gap-1.5 text-green-600 text-xs"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined flex-shrink-0"
|
||||
style={{ fontSize: "14px" }}
|
||||
>
|
||||
check_circle
|
||||
</span>
|
||||
<span className="line-through">{rule.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 강도 바 */}
|
||||
<div className="mt-3">
|
||||
<div className="flex gap-1 mb-1">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1 flex-1 rounded-sm transition-colors ${
|
||||
newPw && i <= strengthLevel
|
||||
? STRENGTH_CONFIG[strengthLevel].color.split(" ")[0]
|
||||
: "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs ${
|
||||
newPw
|
||||
? STRENGTH_CONFIG[strengthLevel].color.split(" ")[1]
|
||||
: "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined flex-shrink-0"
|
||||
style={{ fontSize: "14px" }}
|
||||
>
|
||||
{newPw ? STRENGTH_ICONS[strengthLevel] : "info"}
|
||||
</span>
|
||||
<span>
|
||||
{newPw
|
||||
? STRENGTH_CONFIG[strengthLevel].label
|
||||
: "모든 조건을 충족해야 변경할 수 있습니다"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 새 비밀번호 확인 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[#0f172a] mb-1.5">
|
||||
새 비밀번호 확인 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPw ? "text" : "password"}
|
||||
value={confirmPw}
|
||||
onChange={(e) => setConfirmPw(e.target.value)}
|
||||
className="w-full px-4 py-2.5 pr-10 border border-gray-200 rounded-lg text-sm text-[#0f172a] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/20 focus:border-[#2563EB] transition-colors placeholder:text-gray-300"
|
||||
placeholder="새 비밀번호를 다시 입력해주세요"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPw((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-[#0f172a] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">
|
||||
{showConfirmPw ? "visibility" : "visibility_off"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{passwordMatch && (
|
||||
<div className="flex items-center gap-1.5 text-xs mt-1 text-green-600">
|
||||
<span
|
||||
className="material-symbols-outlined flex-shrink-0"
|
||||
style={{ fontSize: "14px" }}
|
||||
>
|
||||
check_circle
|
||||
</span>
|
||||
<span>비밀번호가 일치합니다</span>
|
||||
</div>
|
||||
)}
|
||||
{passwordMismatch && (
|
||||
<div className="flex items-center gap-1.5 text-xs mt-1 text-red-600">
|
||||
<span
|
||||
className="material-symbols-outlined flex-shrink-0"
|
||||
style={{ fontSize: "14px" }}
|
||||
>
|
||||
error
|
||||
</span>
|
||||
<span>비밀번호가 일치하지 않습니다</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 변경 버튼 */}
|
||||
<div className="flex justify-end pt-4 mt-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!currentPw || !newPw || !confirmPw) return;
|
||||
if (!allRulesPass) return;
|
||||
if (newPw !== confirmPw) return;
|
||||
setShowPwModal(true);
|
||||
}}
|
||||
disabled={!currentPw || !allRulesPass || !passwordMatch}
|
||||
className="px-5 py-2 bg-[#2563EB] text-white rounded-lg text-sm font-medium hover:bg-[#1d4ed8] transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
비밀번호 변경
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-green-500 text-lg">
|
||||
check_circle
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm text-[#0f172a]">
|
||||
비밀번호가 설정되어 있습니다
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
마지막 변경: 2026-01-15
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 보안 설정 (추후 개발) */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-6">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<h3 className="text-sm font-bold text-[#0f172a] flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-base text-[#2563EB]">
|
||||
security
|
||||
</span>
|
||||
보안 설정
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2.5 py-1 rounded-full">
|
||||
추후 개발 예정
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 바 */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
onClick={() => navigate("/settings")}
|
||||
className="flex items-center gap-1.5 px-5 py-2.5 border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-5 py-2.5 border border-gray-200 rounded-lg text-sm font-medium text-[#64748b] hover:bg-gray-50 hover:text-[#0f172a] transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!name.trim()) {
|
||||
setNameError(true);
|
||||
triggerShake(["name"]);
|
||||
return;
|
||||
}
|
||||
setShowSaveModal(true);
|
||||
}}
|
||||
className="px-6 py-2.5 bg-[#2563EB] text-white rounded-lg text-sm font-medium hover:bg-[#1d4ed8] transition-colors shadow-sm"
|
||||
>
|
||||
변경사항 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 변경사항 저장 확인 모달 */}
|
||||
{showSaveModal && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setShowSaveModal(false)}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-md p-6 z-10 border border-gray-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-amber-600 text-xl">
|
||||
warning
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-[#0f172a]">
|
||||
변경사항 저장
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[#0f172a] mb-5">
|
||||
입력한 내용으로 프로필 정보를 변경하시겠습니까?
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowSaveModal(false)}
|
||||
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveConfirm}
|
||||
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 비밀번호 변경 확인 모달 */}
|
||||
{showPwModal && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setShowPwModal(false)}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-md p-6 z-10 border border-gray-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-amber-600 text-xl">
|
||||
warning
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-[#0f172a]">
|
||||
비밀번호 변경 확인
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[#0f172a] mb-2">
|
||||
정말로 비밀번호를 변경하시겠습니까?
|
||||
</p>
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-5">
|
||||
<div className="flex items-center gap-1.5 text-amber-700 text-xs font-medium">
|
||||
<span
|
||||
className="material-symbols-outlined flex-shrink-0"
|
||||
style={{ fontSize: "14px" }}
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<span>
|
||||
변경 후 모든 기기에서 로그아웃되며, 다시 로그인해야 합니다.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowPwModal(false)}
|
||||
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePwConfirm}
|
||||
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,202 @@
|
|||
// Settings feature 타입 정의
|
||||
|
||||
// 사용자 프로필
|
||||
export interface UserProfile {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
team: string;
|
||||
phone: string;
|
||||
joinDate: string;
|
||||
lastLogin: string;
|
||||
}
|
||||
|
||||
// 최근 활동
|
||||
export interface Activity {
|
||||
id: number;
|
||||
type: "send" | "service" | "device" | "auth";
|
||||
title: string;
|
||||
detail: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
// 알림 타입
|
||||
export const NOTIFICATION_TYPES = {
|
||||
SEND: "발송",
|
||||
CERT: "인증서",
|
||||
SERVICE: "서비스",
|
||||
FAIL: "실패",
|
||||
SYSTEM: "시스템",
|
||||
} as const;
|
||||
|
||||
export type NotificationType =
|
||||
(typeof NOTIFICATION_TYPES)[keyof typeof NOTIFICATION_TYPES];
|
||||
|
||||
// 알림
|
||||
export interface Notification {
|
||||
id: number;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
description: string;
|
||||
time: string;
|
||||
read: boolean;
|
||||
date: string; // 그룹핑용 (오늘/어제/이번 주)
|
||||
detail?: string; // 패널에서 보여줄 상세 내용
|
||||
}
|
||||
|
||||
// 목데이터: 사용자 프로필
|
||||
export const MOCK_PROFILE: UserProfile = {
|
||||
name: "Admin User",
|
||||
email: "admin@spms.com",
|
||||
role: "관리자",
|
||||
team: "Team.Stein",
|
||||
phone: "010-1234-5678",
|
||||
joinDate: "2025-03-15",
|
||||
lastLogin: "2026-02-19 09:15",
|
||||
};
|
||||
|
||||
// 목데이터: 최근 활동
|
||||
export const MOCK_ACTIVITIES: Activity[] = [
|
||||
{
|
||||
id: 1,
|
||||
type: "send",
|
||||
title: "메시지 발송 — 마케팅 캠페인 알림",
|
||||
detail: "대상: 1,234건 · 성공률 99.2%",
|
||||
time: "오늘 14:30",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "service",
|
||||
title: "서비스 등록 — (주) 미래테크",
|
||||
detail: "플랫폼: Android (FCM), iOS (APNs)",
|
||||
time: "오늘 11:20",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "device",
|
||||
title: "인증서 갱신 — iOS Production (P8)",
|
||||
detail: "만료일: 2027-02-18",
|
||||
time: "어제 16:45",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "send",
|
||||
title: "발송 실패 처리 — 긴급 공지 알림 (3건)",
|
||||
detail: "원인: 유효하지 않은 토큰",
|
||||
time: "어제 10:00",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: "auth",
|
||||
title: "로그인",
|
||||
detail: "IP: 192.168.1.100 · Chrome 122 / macOS",
|
||||
time: "2월 17일 09:05",
|
||||
},
|
||||
];
|
||||
|
||||
// 목데이터: 알림
|
||||
export const MOCK_NOTIFICATIONS: Notification[] = [
|
||||
{
|
||||
id: 1,
|
||||
type: "발송",
|
||||
title: "발송 완료: 마케팅 캠페인 알림",
|
||||
description:
|
||||
"2026-02-19 14:30에 1,234건 발송이 완료되었습니다. 대상 서비스: (주) 미래테크",
|
||||
time: "14:30",
|
||||
read: false,
|
||||
date: "오늘",
|
||||
detail:
|
||||
"발송 채널: FCM (Android)\n전체 대상: 1,234건\n성공: 1,224건 (99.2%)\n실패: 10건 (0.8%)\n\n실패 사유:\n- 유효하지 않은 토큰: 7건\n- 네트워크 타임아웃: 3건",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "인증서",
|
||||
title: "인증서 만료 예정: iOS Production",
|
||||
description:
|
||||
"7일 후 만료 예정입니다. 서비스 중단 방지를 위해 갱신을 진행해주세요",
|
||||
time: "11:20",
|
||||
read: false,
|
||||
date: "오늘",
|
||||
detail:
|
||||
"인증서 정보:\n- 타입: iOS Production (P8)\n- 발급일: 2025-02-26\n- 만료일: 2026-02-26\n- 남은 일수: 7일\n\n갱신하지 않으면 iOS 푸시 발송이 중단됩니다. [서비스 관리 > 인증서]에서 갱신해주세요.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "서비스",
|
||||
title: "서비스 등록 완료: (주) 미래테크",
|
||||
description:
|
||||
"새 서비스가 정상적으로 등록되었습니다. 플랫폼 인증을 진행해주세요",
|
||||
time: "09:45",
|
||||
read: false,
|
||||
date: "오늘",
|
||||
detail:
|
||||
"서비스명: (주) 미래테크\n서비스 ID: SVC-2026-0042\n등록일: 2026-02-19\n\n등록 플랫폼:\n- Android (FCM): 인증 완료\n- iOS (APNs): 인증 대기\n\niOS 인증서를 등록하면 iOS 기기에도 푸시를 발송할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "실패",
|
||||
title: "발송 실패: 긴급 공지 알림 (3건)",
|
||||
description: "유효하지 않은 토큰으로 인해 발송에 실패했습니다",
|
||||
time: "08:15",
|
||||
read: true,
|
||||
date: "오늘",
|
||||
detail:
|
||||
"발송 ID: SEND-20260219-003\n대상 서비스: Smart Factory\n실패 건수: 3건\n\n실패 상세:\n- device_token_expired: 2건\n- invalid_registration: 1건\n\n해당 기기 토큰을 갱신하거나 삭제 후 재발송해주세요.",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: "시스템",
|
||||
title: "시스템 점검 완료",
|
||||
description: "정기 점검이 완료되어 정상 운영 중입니다",
|
||||
time: "23:00",
|
||||
read: true,
|
||||
date: "어제",
|
||||
detail:
|
||||
"점검 시간: 2026-02-18 22:00 ~ 23:00\n점검 내용: 서버 보안 패치 및 DB 최적화\n\n영향 범위: 전체 서비스 일시 중단\n현재 상태: 정상 운영 중\n\n다음 정기 점검 예정: 2026-03-18",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: "발송",
|
||||
title: "발송 완료: 앱 업데이트 공지",
|
||||
description: "5,678건 발송 완료. 대상 서비스: Smart Factory",
|
||||
time: "16:45",
|
||||
read: true,
|
||||
date: "어제",
|
||||
detail:
|
||||
"발송 채널: FCM (Android) + APNs (iOS)\n전체 대상: 5,678건\n성공: 5,650건 (99.5%)\n실패: 28건 (0.5%)\n\n서비스: Smart Factory\n메시지 제목: 앱 업데이트 v2.5.0 안내",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
type: "인증서",
|
||||
title: "인증서 갱신 완료: Android FCM",
|
||||
description: "FCM 서버 키가 정상적으로 갱신되었습니다",
|
||||
time: "10:00",
|
||||
read: true,
|
||||
date: "어제",
|
||||
detail:
|
||||
"인증서 정보:\n- 타입: Android FCM (Server Key)\n- 이전 만료일: 2026-02-20\n- 갱신 후 만료일: 2027-02-18\n\n갱신은 자동으로 적용되었으며, 별도 조치가 필요하지 않습니다.",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: "발송",
|
||||
title: "발송 완료: 주간 리포트 알림",
|
||||
description: "892건 발송 완료. 대상 서비스: (주) 미래테크",
|
||||
time: "02.17 09:30",
|
||||
read: true,
|
||||
date: "이번 주",
|
||||
detail:
|
||||
"발송 채널: FCM (Android)\n전체 대상: 892건\n성공: 890건 (99.8%)\n실패: 2건 (0.2%)\n\n서비스: (주) 미래테크\n메시지 제목: 주간 리포트 (2026.02.10 ~ 02.16)",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
type: "시스템",
|
||||
title: "정기 점검 예정 안내",
|
||||
description:
|
||||
"2026-02-18 22:00 ~ 23:00 정기 점검이 예정되어 있습니다",
|
||||
time: "02.16 02:00",
|
||||
read: true,
|
||||
date: "이번 주",
|
||||
detail:
|
||||
"점검 예정 시간: 2026-02-18 22:00 ~ 23:00\n점검 내용: 서버 보안 패치 및 DB 최적화\n\n영향 범위:\n- 전체 서비스 일시 중단\n- API 호출 불가\n- 관리자 콘솔 접속 불가\n\n점검 중 발송 예약 건은 점검 완료 후 자동 재개됩니다.",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user