diff --git a/react/src/api/account.api.ts b/react/src/api/account.api.ts new file mode 100644 index 0000000..5c78b70 --- /dev/null +++ b/react/src/api/account.api.ts @@ -0,0 +1,41 @@ +import { apiClient } from "./client"; +import type { ApiResponse } from "@/types/api"; +import type { + ProfileResponse, + UpdateProfileRequest, + ActivityListRequest, + ActivityListResponse, + ChangePasswordRequest, + ChangePasswordResponse, +} from "@/features/settings/types"; + +/** 내 프로필 조회 */ +export function fetchProfile() { + return apiClient.post>( + "/v1/in/account/profile/info", + ); +} + +/** 프로필 수정 */ +export function updateProfile(data: UpdateProfileRequest) { + return apiClient.post>( + "/v1/in/account/profile/update", + data, + ); +} + +/** 활동 내역 조회 */ +export function fetchActivityList(data: ActivityListRequest) { + return apiClient.post>( + "/v1/in/account/profile/activity/list", + data, + ); +} + +/** 비밀번호 변경 */ +export function changePassword(data: ChangePasswordRequest) { + return apiClient.post>( + "/v1/in/auth/password/change", + data, + ); +} diff --git a/react/src/features/settings/pages/MyPage.tsx b/react/src/features/settings/pages/MyPage.tsx index f3c2033..f430b1d 100644 --- a/react/src/features/settings/pages/MyPage.tsx +++ b/react/src/features/settings/pages/MyPage.tsx @@ -1,11 +1,15 @@ -import { useState, useMemo } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import PageHeader from "@/components/common/PageHeader"; import Pagination from "@/components/common/Pagination"; +import { fetchProfile, fetchActivityList } from "@/api/account.api"; +import { formatDate, formatDateTime, formatRelativeTime } from "@/utils/format"; import { - MOCK_PROFILE, - MOCK_ACTIVITIES, + ROLE_LABELS, + type UserProfile, type Activity, + type ProfileResponse, + type ActivityItem, } from "../types"; // 활동 타입별 아이콘 + 색상 매핑 @@ -21,27 +25,103 @@ const ACTIVITY_STYLE: Record< const PAGE_SIZE = 5; +/** ProfileResponse → UserProfile 뷰모델 변환 */ +function mapProfile(res: ProfileResponse): UserProfile { + return { + name: res.name ?? "", + email: res.email ?? "", + role: ROLE_LABELS[res.role] ?? `역할(${res.role})`, + team: res.organization ?? "", + phone: res.phone ?? "", + joinDate: res.created_at ? formatDate(res.created_at) : "", + lastLogin: res.last_login_at ? formatDateTime(res.last_login_at) : "", + }; +} + +/** 활동 타입 매핑 */ +function mapActivityType(type: string | null): Activity["type"] { + if (type === "send" || type === "service" || type === "device" || type === "auth") return type; + return "auth"; // fallback +} + +/** ActivityItem → Activity 뷰모델 변환 */ +function mapActivity(item: ActivityItem, index: number): Activity { + return { + id: index, + type: mapActivityType(item.activity_type), + title: item.title ?? "", + detail: item.description ?? "", + time: item.occurred_at ? formatRelativeTime(item.occurred_at) : "", + }; +} + export default function MyPage() { const navigate = useNavigate(); - const profile = MOCK_PROFILE; + + // 프로필 상태 + const [profile, setProfile] = useState(null); + + // 활동 내역 상태 + const [activities, setActivities] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const [totalItems, setTotalItems] = useState(0); + const [totalPages, setTotalPages] = useState(1); + + // 프로필 로드 + useEffect(() => { + (async () => { + try { + const res = await fetchProfile(); + setProfile(mapProfile(res.data.data)); + } catch { + // 에러 시 빈 프로필 유지 + } + })(); + }, []); + + // 활동 내역 로드 + const loadActivities = useCallback(async (page: number) => { + try { + const res = await fetchActivityList({ page, size: PAGE_SIZE }); + const data = res.data.data; + setActivities((data.items ?? []).map(mapActivity)); + setTotalItems(data.pagination.total_count); + setTotalPages(data.pagination.total_pages || 1); + } catch { + setActivities([]); + setTotalItems(0); + setTotalPages(1); + } + }, []); + + useEffect(() => { + loadActivities(currentPage); + }, [currentPage, loadActivities]); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + // 프로필 로딩 중 + if (!profile) { + return ( + <> + +
+ 불러오는 중... +
+ + ); + } + 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], - ); + .toUpperCase() || "?"; return ( <> @@ -154,36 +234,42 @@ export default function MyPage() { 최근 7일
- {pagedActivities.map((activity) => { - const style = ACTIVITY_STYLE[activity.type]; - return ( -
+ {activities.length === 0 ? ( +
+ 활동 내역이 없습니다 +
+ ) : ( + activities.map((activity) => { + const style = ACTIVITY_STYLE[activity.type]; + return (
- - {style.icon} + + {style.icon} + +
+
+

+ {activity.title} +

+

+ {activity.detail} +

+
+ + {activity.time}
-
-

- {activity.title} -

-

- {activity.detail} -

-
- - {activity.time} - -
- ); - })} + ); + }) + )} {/* 페이지네이션 */}
@@ -192,7 +278,7 @@ export default function MyPage() { totalPages={totalPages} totalItems={totalItems} pageSize={PAGE_SIZE} - onPageChange={setCurrentPage} + onPageChange={handlePageChange} />
diff --git a/react/src/features/settings/pages/ProfileEditPage.tsx b/react/src/features/settings/pages/ProfileEditPage.tsx index 0080e37..fd573a1 100644 --- a/react/src/features/settings/pages/ProfileEditPage.tsx +++ b/react/src/features/settings/pages/ProfileEditPage.tsx @@ -1,9 +1,9 @@ -import { useState, useMemo, useCallback } from "react"; +import { useState, useEffect, 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"; +import { fetchProfile, updateProfile, changePassword } from "@/api/account.api"; // 비밀번호 규칙 정의 const PASSWORD_RULES = [ @@ -35,6 +35,14 @@ const STRENGTH_CONFIG = [ const STRENGTH_ICONS = ["", "error", "warning", "info", "check_circle"]; +/** 원본 프로필 (초기화용) */ +interface OriginalProfile { + name: string; + phone: string; + team: string; + email: string; +} + export default function ProfileEditPage() { const navigate = useNavigate(); @@ -43,9 +51,12 @@ export default function ProfileEditPage() { 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 [name, setName] = useState(""); + const [phone, setPhone] = useState(""); + const [team, setTeam] = useState(""); + const [email, setEmail] = useState(""); + const [originalProfile, setOriginalProfile] = useState(null); + const [loading, setLoading] = useState(true); // 비밀번호 섹션 const [showPasswordForm, setShowPasswordForm] = useState(false); @@ -60,6 +71,31 @@ export default function ProfileEditPage() { const [showSaveModal, setShowSaveModal] = useState(false); const [showPwModal, setShowPwModal] = useState(false); + // 프로필 로드 + useEffect(() => { + (async () => { + try { + const res = await fetchProfile(); + const data = res.data.data; + const profile: OriginalProfile = { + name: data.name ?? "", + phone: data.phone ?? "", + team: data.organization ?? "", + email: data.email ?? "", + }; + setName(profile.name); + setPhone(profile.phone); + setTeam(profile.team); + setEmail(profile.email); + setOriginalProfile(profile); + } catch { + toast.error("프로필 정보를 불러오지 못했습니다."); + } finally { + setLoading(false); + } + })(); + }, []); + // 비밀번호 규칙 통과 여부 const ruleResults = useMemo( () => PASSWORD_RULES.map((r) => ({ ...r, pass: newPw ? r.test(newPw) : false })), @@ -85,26 +121,64 @@ export default function ProfileEditPage() { }, [showPasswordForm]); // 저장 확인 - const handleSaveConfirm = useCallback(() => { + const handleSaveConfirm = useCallback(async () => { + try { + await updateProfile({ + name: name.trim(), + phone, + organization: team, + }); + toast.success("변경사항이 저장되었습니다"); + navigate("/settings"); + } catch { + toast.error("프로필 수정에 실패했습니다."); + } setShowSaveModal(false); - toast.success("변경사항이 저장되었습니다"); - navigate("/settings"); - }, [navigate]); + }, [name, phone, team, navigate]); // 비밀번호 변경 확인 - const handlePwConfirm = useCallback(() => { + const handlePwConfirm = useCallback(async () => { + try { + const res = await changePassword({ + currentPassword: currentPw, + newPassword: newPw, + }); + if (res.data.data.re_login_required) { + toast.success("비밀번호가 변경되었습니다. 다시 로그인해주세요."); + navigate("/auth/login"); + } else { + toast.success("비밀번호가 변경되었습니다."); + navigate("/settings"); + } + } catch { + toast.error("비밀번호 변경에 실패했습니다."); + } setShowPwModal(false); - toast.success("비밀번호가 변경되었습니다"); - navigate("/settings"); - }, [navigate]); + }, [currentPw, newPw, navigate]); // 초기화 const handleReset = useCallback(() => { - setName(MOCK_PROFILE.name); - setPhone(MOCK_PROFILE.phone); - setTeam(MOCK_PROFILE.team); + if (originalProfile) { + setName(originalProfile.name); + setPhone(originalProfile.phone); + setTeam(originalProfile.team); + } setNameError(false); - }, []); + }, [originalProfile]); + + if (loading) { + return ( + <> + +
+ 불러오는 중... +
+ + ); + } return ( <> @@ -163,7 +237,7 @@ export default function ProfileEditPage() { diff --git a/react/src/features/settings/types.ts b/react/src/features/settings/types.ts index e609ce3..f9be87e 100644 --- a/react/src/features/settings/types.ts +++ b/react/src/features/settings/types.ts @@ -44,55 +44,76 @@ export interface Notification { 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", -}; +// --- API 응답 타입 --- -// 목데이터: 최근 활동 -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", - }, -]; +/** 프로필 응답 (ProfileResponseDto) */ +export interface ProfileResponse { + admin_code: string | null; + email: string | null; + name: string | null; + phone: string | null; + role: number; + created_at: string; + last_login_at: string | null; + organization: string | null; +} + +/** 역할 매핑 */ +export const ROLE_LABELS: Record = { + 1: "관리자", + 2: "운영자", +} as const; + +// --- API 요청 타입 --- + +/** 프로필 수정 요청 */ +export interface UpdateProfileRequest { + name?: string | null; + phone?: string | null; + organization?: string | null; +} + +/** 활동 내역 요청 */ +export interface ActivityListRequest { + page: number; + size: number; + from?: string | null; + to?: string | null; +} + +/** 활동 내역 항목 (ActivityItemDto) */ +export interface ActivityItem { + activity_type: string | null; + title: string | null; + description: string | null; + ip_address: string | null; + occurred_at: string; +} + +/** 활동 내역 페이지네이션 */ +export interface ActivityPagination { + page: number; + size: number; + total_count: number; + total_pages: number; +} + +/** 활동 내역 응답 */ +export interface ActivityListResponse { + items: ActivityItem[] | null; + pagination: ActivityPagination; +} + +/** 비밀번호 변경 요청 */ +export interface ChangePasswordRequest { + currentPassword: string; + newPassword: string; +} + +/** 비밀번호 변경 응답 */ +export interface ChangePasswordResponse { + re_login_required: boolean; +} // 목데이터: 알림 export const MOCK_NOTIFICATIONS: Notification[] = [