feat: 마이페이지 + 프로필 수정 API 연동 (#43)
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Some checks failed
SPMS_BO/pipeline/head There was a failure building this commit
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/44
This commit is contained in:
commit
b522f968ee
41
react/src/api/account.api.ts
Normal file
41
react/src/api/account.api.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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<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
|
||||
.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() {
|
|||
<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"
|
||||
>
|
||||
{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];
|
||||
return (
|
||||
<div
|
||||
className={`size-9 rounded-full ${style.bg} flex items-center justify-center flex-shrink-0`}
|
||||
key={activity.id}
|
||||
className="flex items-center gap-4 px-6 py-4 hover:bg-gray-50/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined ${style.text} text-lg`}
|
||||
<div
|
||||
className={`size-9 rounded-full ${style.bg} flex items-center justify-center flex-shrink-0`}
|
||||
>
|
||||
{style.icon}
|
||||
<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 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">
|
||||
|
|
@ -192,7 +278,7 @@ export default function MyPage() {
|
|||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<OriginalProfile | null>(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 (
|
||||
<>
|
||||
<PageHeader
|
||||
title="프로필 수정"
|
||||
description="계정 정보와 보안 설정을 수정할 수 있습니다"
|
||||
/>
|
||||
<div className="flex items-center justify-center py-20 text-sm text-gray-400">
|
||||
불러오는 중...
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -163,7 +237,7 @@ export default function ProfileEditPage() {
|
|||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={MOCK_PROFILE.email}
|
||||
value={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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<number, string> = {
|
||||
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[] = [
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user