From ca88d5ba0812b70dbb63dd96e3da4ad2ef5262be Mon Sep 17 00:00:00 2001 From: SEAN Date: Sat, 28 Feb 2026 16:30:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=A4=EC=A0=95=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EC=83=81=EC=84=B8=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 상세 슬라이드 패널 (NotificationSlidePanel) 신규 생성 - 헤더 알림 드롭다운에서 클릭 시 알림 페이지 이동 + 패널 자동 오픈 - 프로필 수정 필수값 검증: useShake + 인라인 에러 메시지 패턴 적용 - 사이드바 프로필 아이콘 클릭 시 마이페이지 이동 - 사이드바 메뉴 그룹 경로 변경 시 자동 접힘 처리 - 대시보드, 마이페이지 UI 개선 Co-Authored-By: Claude Opus 4.6 --- react/src/components/layout/AppHeader.tsx | 73 ++- react/src/components/layout/AppSidebar.tsx | 15 +- .../src/features/dashboard/pages/HomePage.tsx | 106 +++- .../components/NotificationSlidePanel.tsx | 185 ++++++ react/src/features/settings/pages/MyPage.tsx | 215 ++++++- .../settings/pages/NotificationsPage.tsx | 247 +++++++- .../settings/pages/ProfileEditPage.tsx | 583 +++++++++++++++++- react/src/features/settings/types.ts | 201 ++++++ 8 files changed, 1582 insertions(+), 43 deletions(-) create mode 100644 react/src/features/settings/components/NotificationSlidePanel.tsx diff --git a/react/src/components/layout/AppHeader.tsx b/react/src/components/layout/AppHeader.tsx index c529832..d539ff0 100644 --- a/react/src/components/layout/AppHeader.tsx +++ b/react/src/components/layout/AppHeader.tsx @@ -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 = { @@ -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(null); @@ -179,19 +181,32 @@ export default function AppHeader() { {/* 알림 목록 */}
- {MOCK_NOTIFICATIONS.map((noti, i) => ( -
-
- -

{noti.title}

- {noti.time} + {HEADER_NOTIFICATIONS.map((noti) => { + const badge = NOTI_BADGE_MAP[noti.type]; + return ( +
{ + 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" : "" + }`} + > +
+ +

+ {noti.title} +

+ {noti.time} +
+

{noti.description}

-

{noti.description}

-
- ))} + ); + })}
)} diff --git a/react/src/components/layout/AppSidebar.tsx b/react/src/components/layout/AppSidebar.tsx index d08980f..2858d22 100644 --- a/react/src/components/layout/AppSidebar.tsx +++ b/react/src/components/layout/AppSidebar.tsx @@ -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 (
+
+ + {/* 본문 */} +
+ {notification && badge ? ( + <> + {/* 타입 배지 + 시간 + 날짜 */} +
+ + + {badge.icon} + + {notification.type} + + + {notification.date} {notification.time} + + {/* 읽음 상태 */} + {notification.read ? ( + + + done_all + + 읽음 + + ) : ( + + + 미읽음 + + )} +
+ + {/* 제목 */} +

+ {notification.title} +

+ + {/* 설명 (전체 표시) */} +

+ {notification.description} +

+ + {/* 상세 내용 */} + {notification.detail && ( +
+

+ 상세 내용 +

+
+
+                      {notification.detail}
+                    
+
+
+ )} + + ) : ( +
+ 알림을 선택해주세요 +
+ )} +
+ + {/* 푸터 */} +
+ {notification && !notification.read && ( + + )} + +
+ + + ); +} diff --git a/react/src/features/settings/pages/MyPage.tsx b/react/src/features/settings/pages/MyPage.tsx index 70ae8f0..f3c2033 100644 --- a/react/src/features/settings/pages/MyPage.tsx +++ b/react/src/features/settings/pages/MyPage.tsx @@ -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 ( -
-

마이페이지

-
+ <> + 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" + > + edit + 프로필 수정 + + } + /> + + {/* 프로필 카드 */} +
+
+
+ {/* 아바타 */} +
+ {initials} +
+ {/* 기본 정보 */} +
+
+

+ {profile.name} +

+ + + shield + + {profile.role} + +
+

{profile.email}

+

+ 마지막 로그인: {profile.lastLogin} (KST) +

+
+
+
+
+ + {/* 2열 그리드 */} +
+ {/* 계정 정보 */} +
+
+

+ + person + + 계정 정보 +

+
+
+ {[ + { label: "소속", value: profile.team }, + { label: "연락처", value: profile.phone }, + { label: "가입일", value: profile.joinDate }, + { label: "최근 로그인", value: profile.lastLogin }, + ].map((item, i, arr) => ( +
+
+ {item.label} + + {item.value} + +
+ {i < arr.length - 1 && ( +
+ )} +
+ ))} +
+
+ + {/* 보안 설정 (추후 개발) */} +
+
+

+ + security + + 보안 설정 +

+ + 추후 개발 예정 + +
+
+
+ + {/* 최근 활동 내역 */} +
+
+

+ + history + + 최근 활동 내역 +

+ 최근 7일 +
+
+ {pagedActivities.map((activity) => { + const style = ACTIVITY_STYLE[activity.type]; + return ( +
+
+ + {style.icon} + +
+
+

+ {activity.title} +

+

+ {activity.detail} +

+
+ + {activity.time} + +
+ ); + })} +
+ {/* 페이지네이션 */} +
+ +
+
+ + {/* 접속 기기 (추후 개발) */} +
+
+

+ + devices + + 접속 기기 +

+ + 추후 개발 예정 + +
+
+ ); } diff --git a/react/src/features/settings/pages/NotificationsPage.tsx b/react/src/features/settings/pages/NotificationsPage.tsx index a259f08..037d113 100644 --- a/react/src/features/settings/pages/NotificationsPage.tsx +++ b/react/src/features/settings/pages/NotificationsPage.tsx @@ -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 = { + "오늘": "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( + () => [...MOCK_NOTIFICATIONS], + ); + const [currentPage, setCurrentPage] = useState(1); + const [selectedNotification, setSelectedNotification] = + useState(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(); + 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 ( -
-

알림 설정

-
+ <> + + + done_all + + 전체 읽음 + + } + /> + + {/* 날짜별 그룹 */} + {Array.from(grouped.entries()).map(([dateLabel, items]) => ( +
+ {/* 그룹 헤더 */} +
+

+ {dateLabel} +

+
+ + {DATE_LABELS[dateLabel] ?? ""} + +
+ + {/* 알림 카드 목록 */} +
+ {items.map((notification, idx) => { + const badge = BADGE_STYLE[notification.type]; + const isUnread = !notification.read; + return ( +
{ + 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" + }`} + > + {/* 읽음 상태 점 */} +
+
+
+ {/* 콘텐츠 */} +
+
+ + + {badge.icon} + + {notification.type} + + + {notification.time} + +
+

+ {notification.title} +

+

+ {notification.description} +

+
+
+ ); + })} +
+
+ ))} + + {/* 페이지네이션 */} +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + ))} + +
+ + {/* 알림 상세 패널 */} + setSelectedNotification(null)} + notification={selectedNotification} + onMarkRead={handleReadOne} + /> + ); } diff --git a/react/src/features/settings/pages/ProfileEditPage.tsx b/react/src/features/settings/pages/ProfileEditPage.tsx index b3e8154..0080e37 100644 --- a/react/src/features/settings/pages/ProfileEditPage.tsx +++ b/react/src/features/settings/pages/ProfileEditPage.tsx @@ -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 ( -
-

프로필 수정

-
+ <> + + + {/* 기본 정보 카드 */} +
+
+

+ + person + + 기본 정보 +

+
+
+
+ {/* 이름 */} +
+ + { + 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 && ( +
+ + error + + 이름을 입력해주세요. +
+ )} +
+ {/* 이메일 (읽기전용) */} +
+ + +
+ + info + + 이메일은 관리자만 변경할 수 있습니다 +
+
+ {/* 연락처 */} +
+ + 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" + /> +
+ {/* 소속 */} +
+ + 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="소속을 입력해주세요" + /> +
+
+
+
+ + {/* 비밀번호 변경 카드 */} +
+
+

+ + lock + + 비밀번호 변경 +

+ +
+ + {showPasswordForm ? ( +
+
+ {/* 현재 비밀번호 */} +
+ +
+ 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="현재 비밀번호를 입력해주세요" + /> + +
+
+ + {/* 새 비밀번호 */} +
+ +
+ 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="새 비밀번호를 입력해주세요" + /> + +
+ + {/* 비밀번호 규칙 */} + {newPw && ( +
+ {ruleResults + .filter((r) => !r.pass) + .map((rule) => ( +
+ + keyboard + + {rule.label} +
+ ))} + {ruleResults + .filter((r) => r.pass) + .map((rule) => ( +
+ + check_circle + + {rule.label} +
+ ))} +
+ )} + + {/* 강도 바 */} +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+ + {newPw ? STRENGTH_ICONS[strengthLevel] : "info"} + + + {newPw + ? STRENGTH_CONFIG[strengthLevel].label + : "모든 조건을 충족해야 변경할 수 있습니다"} + +
+
+
+ + {/* 새 비밀번호 확인 */} +
+ +
+ 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="새 비밀번호를 다시 입력해주세요" + /> + +
+ {passwordMatch && ( +
+ + check_circle + + 비밀번호가 일치합니다 +
+ )} + {passwordMismatch && ( +
+ + error + + 비밀번호가 일치하지 않습니다 +
+ )} +
+
+ + {/* 비밀번호 변경 버튼 */} +
+ +
+
+ ) : ( +
+
+ + check_circle + +
+

+ 비밀번호가 설정되어 있습니다 +

+

+ 마지막 변경: 2026-01-15 +

+
+
+
+ )} +
+ + {/* 보안 설정 (추후 개발) */} +
+
+

+ + security + + 보안 설정 +

+ + 추후 개발 예정 + +
+
+ + {/* 하단 버튼 바 */} +
+ +
+ + +
+
+ + {/* 변경사항 저장 확인 모달 */} + {showSaveModal && ( +
+
setShowSaveModal(false)} + /> +
+
+
+
+ + warning + +
+

+ 변경사항 저장 +

+
+

+ 입력한 내용으로 프로필 정보를 변경하시겠습니까? +

+
+ + +
+
+
+
+ )} + + {/* 비밀번호 변경 확인 모달 */} + {showPwModal && ( +
+
setShowPwModal(false)} + /> +
+
+
+
+ + warning + +
+

+ 비밀번호 변경 확인 +

+
+

+ 정말로 비밀번호를 변경하시겠습니까? +

+
+
+ + info + + + 변경 후 모든 기기에서 로그아웃되며, 다시 로그인해야 합니다. + +
+
+
+ + +
+
+
+
+ )} + ); } diff --git a/react/src/features/settings/types.ts b/react/src/features/settings/types.ts index d8bd4d1..e609ce3 100644 --- a/react/src/features/settings/types.ts +++ b/react/src/features/settings/types.ts @@ -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점검 중 발송 예약 건은 점검 완료 후 자동 재개됩니다.", + }, +];