feat: 마이페이지 + 프로필 수정 API 연동 (#43) #44

Merged
seonkyu.kim merged 1 commits from feature/SPMS-43-mypage-profile-api into develop 2026-03-02 12:39:15 +00:00
4 changed files with 332 additions and 110 deletions

View File

@ -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<ApiResponse<ProfileResponse>>(
"/v1/in/account/profile/info",
);
}
/** 프로필 수정 */
export function updateProfile(data: UpdateProfileRequest) {
return apiClient.post<ApiResponse<ProfileResponse>>(
"/v1/in/account/profile/update",
data,
);
}
/** 활동 내역 조회 */
export function fetchActivityList(data: ActivityListRequest) {
return apiClient.post<ApiResponse<ActivityListResponse>>(
"/v1/in/account/profile/activity/list",
data,
);
}
/** 비밀번호 변경 */
export function changePassword(data: ChangePasswordRequest) {
return apiClient.post<ApiResponse<ChangePasswordResponse>>(
"/v1/in/auth/password/change",
data,
);
}

View File

@ -1,11 +1,15 @@
import { useState, useMemo } from "react"; import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import Pagination from "@/components/common/Pagination"; import Pagination from "@/components/common/Pagination";
import { fetchProfile, fetchActivityList } from "@/api/account.api";
import { formatDate, formatDateTime, formatRelativeTime } from "@/utils/format";
import { import {
MOCK_PROFILE, ROLE_LABELS,
MOCK_ACTIVITIES, type UserProfile,
type Activity, type Activity,
type ProfileResponse,
type ActivityItem,
} from "../types"; } from "../types";
// 활동 타입별 아이콘 + 색상 매핑 // 활동 타입별 아이콘 + 색상 매핑
@ -21,27 +25,103 @@ const ACTIVITY_STYLE: Record<
const PAGE_SIZE = 5; 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() { export default function MyPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const profile = MOCK_PROFILE;
// 프로필 상태
const [profile, setProfile] = useState<UserProfile | null>(null);
// 활동 내역 상태
const [activities, setActivities] = useState<Activity[]>([]);
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 (
<>
<PageHeader
title="마이페이지"
description="내 계정 정보와 활동 내역을 확인하고 관리할 수 있습니다"
/>
<div className="flex items-center justify-center py-20 text-sm text-gray-400">
...
</div>
</>
);
}
const initials = profile.name const initials = profile.name
.split(" ") .split(" ")
.map((w) => w[0]) .map((w) => w[0])
.join("") .join("")
.toUpperCase(); .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 ( return (
<> <>
@ -154,7 +234,12 @@ export default function MyPage() {
<span className="text-xs text-[#64748b]"> 7</span> <span className="text-xs text-[#64748b]"> 7</span>
</div> </div>
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
{pagedActivities.map((activity) => { {activities.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
</div>
) : (
activities.map((activity) => {
const style = ACTIVITY_STYLE[activity.type]; const style = ACTIVITY_STYLE[activity.type];
return ( return (
<div <div
@ -183,7 +268,8 @@ export default function MyPage() {
</span> </span>
</div> </div>
); );
})} })
)}
</div> </div>
{/* 페이지네이션 */} {/* 페이지네이션 */}
<div className="border-t border-gray-100"> <div className="border-t border-gray-100">
@ -192,7 +278,7 @@ export default function MyPage() {
totalPages={totalPages} totalPages={totalPages}
totalItems={totalItems} totalItems={totalItems}
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
onPageChange={setCurrentPage} onPageChange={handlePageChange}
/> />
</div> </div>
</div> </div>

View File

@ -1,9 +1,9 @@
import { useState, useMemo, useCallback } from "react"; import { useState, useEffect, useMemo, useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import useShake from "@/hooks/useShake"; import useShake from "@/hooks/useShake";
import { MOCK_PROFILE } from "../types"; import { fetchProfile, updateProfile, changePassword } from "@/api/account.api";
// 비밀번호 규칙 정의 // 비밀번호 규칙 정의
const PASSWORD_RULES = [ const PASSWORD_RULES = [
@ -35,6 +35,14 @@ const STRENGTH_CONFIG = [
const STRENGTH_ICONS = ["", "error", "warning", "info", "check_circle"]; const STRENGTH_ICONS = ["", "error", "warning", "info", "check_circle"];
/** 원본 프로필 (초기화용) */
interface OriginalProfile {
name: string;
phone: string;
team: string;
email: string;
}
export default function ProfileEditPage() { export default function ProfileEditPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -43,9 +51,12 @@ export default function ProfileEditPage() {
const { triggerShake, cls } = useShake(); const { triggerShake, cls } = useShake();
// 기본 정보 폼 // 기본 정보 폼
const [name, setName] = useState(MOCK_PROFILE.name); const [name, setName] = useState("");
const [phone, setPhone] = useState(MOCK_PROFILE.phone); const [phone, setPhone] = useState("");
const [team, setTeam] = useState(MOCK_PROFILE.team); const [team, setTeam] = useState("");
const [email, setEmail] = useState("");
const [originalProfile, setOriginalProfile] = useState<OriginalProfile | null>(null);
const [loading, setLoading] = useState(true);
// 비밀번호 섹션 // 비밀번호 섹션
const [showPasswordForm, setShowPasswordForm] = useState(false); const [showPasswordForm, setShowPasswordForm] = useState(false);
@ -60,6 +71,31 @@ export default function ProfileEditPage() {
const [showSaveModal, setShowSaveModal] = useState(false); const [showSaveModal, setShowSaveModal] = useState(false);
const [showPwModal, setShowPwModal] = 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( const ruleResults = useMemo(
() => PASSWORD_RULES.map((r) => ({ ...r, pass: newPw ? r.test(newPw) : false })), () => PASSWORD_RULES.map((r) => ({ ...r, pass: newPw ? r.test(newPw) : false })),
@ -85,26 +121,64 @@ export default function ProfileEditPage() {
}, [showPasswordForm]); }, [showPasswordForm]);
// 저장 확인 // 저장 확인
const handleSaveConfirm = useCallback(() => { const handleSaveConfirm = useCallback(async () => {
setShowSaveModal(false); try {
await updateProfile({
name: name.trim(),
phone,
organization: team,
});
toast.success("변경사항이 저장되었습니다"); toast.success("변경사항이 저장되었습니다");
navigate("/settings"); navigate("/settings");
}, [navigate]); } catch {
toast.error("프로필 수정에 실패했습니다.");
}
setShowSaveModal(false);
}, [name, phone, team, navigate]);
// 비밀번호 변경 확인 // 비밀번호 변경 확인
const handlePwConfirm = useCallback(() => { const handlePwConfirm = useCallback(async () => {
setShowPwModal(false); try {
toast.success("비밀번호가 변경되었습니다"); 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"); navigate("/settings");
}, [navigate]); }
} catch {
toast.error("비밀번호 변경에 실패했습니다.");
}
setShowPwModal(false);
}, [currentPw, newPw, navigate]);
// 초기화 // 초기화
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setName(MOCK_PROFILE.name); if (originalProfile) {
setPhone(MOCK_PROFILE.phone); setName(originalProfile.name);
setTeam(MOCK_PROFILE.team); setPhone(originalProfile.phone);
setTeam(originalProfile.team);
}
setNameError(false); setNameError(false);
}, []); }, [originalProfile]);
if (loading) {
return (
<>
<PageHeader
title="프로필 수정"
description="계정 정보와 보안 설정을 수정할 수 있습니다"
/>
<div className="flex items-center justify-center py-20 text-sm text-gray-400">
...
</div>
</>
);
}
return ( return (
<> <>
@ -163,7 +237,7 @@ export default function ProfileEditPage() {
</label> </label>
<input <input
type="email" type="email"
value={MOCK_PROFILE.email} value={email}
readOnly 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" 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"
/> />

View File

@ -44,55 +44,76 @@ export interface Notification {
detail?: string; // 패널에서 보여줄 상세 내용 detail?: string; // 패널에서 보여줄 상세 내용
} }
// 목데이터: 사용자 프로필 // --- API 응답 타입 ---
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",
};
// 목데이터: 최근 활동 /** 프로필 응답 (ProfileResponseDto) */
export const MOCK_ACTIVITIES: Activity[] = [ export interface ProfileResponse {
{ admin_code: string | null;
id: 1, email: string | null;
type: "send", name: string | null;
title: "메시지 발송 — 마케팅 캠페인 알림", phone: string | null;
detail: "대상: 1,234건 · 성공률 99.2%", role: number;
time: "오늘 14:30", created_at: string;
}, last_login_at: string | null;
{ organization: string | null;
id: 2, }
type: "service",
title: "서비스 등록 — (주) 미래테크", /** 역할 매핑 */
detail: "플랫폼: Android (FCM), iOS (APNs)", export const ROLE_LABELS: Record<number, string> = {
time: "오늘 11:20", 1: "관리자",
}, 2: "운영자",
{ } as const;
id: 3,
type: "device", // --- API 요청 타입 ---
title: "인증서 갱신 — iOS Production (P8)",
detail: "만료일: 2027-02-18", /** 프로필 수정 요청 */
time: "어제 16:45", export interface UpdateProfileRequest {
}, name?: string | null;
{ phone?: string | null;
id: 4, organization?: string | null;
type: "send", }
title: "발송 실패 처리 — 긴급 공지 알림 (3건)",
detail: "원인: 유효하지 않은 토큰", /** 활동 내역 요청 */
time: "어제 10:00", export interface ActivityListRequest {
}, page: number;
{ size: number;
id: 5, from?: string | null;
type: "auth", to?: string | null;
title: "로그인", }
detail: "IP: 192.168.1.100 · Chrome 122 / macOS",
time: "2월 17일 09:05", /** 활동 내역 항목 (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[] = [ export const MOCK_NOTIFICATIONS: Notification[] = [