From c4bcdab0a88eb6e4ea1bfc8a8d283ca86d7cd7e5 Mon Sep 17 00:00:00 2001 From: BcKmini <151009045+BcKmini@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:40:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20=EB=B6=80?= =?UTF-8?q?=EA=B0=80=20=EC=A0=95=EB=B3=B4(=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=C2=B7=EB=B3=B4=EC=95=88=C2=B7=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=84=A4=EC=A0=95)=20=EC=8B=A4=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfilePage에 남아있던 나머지 하드코딩(fowoco/server PR #171에서 새로 추가된 로그인 이력·계정 상태·비밀번호 변경일·알림 수신 설정 API)을 화면에 실제로 연결한다. - 마지막 로그인 시각/기기, 계정 보호 상태, 비밀번호 변경일을 GET /auth/me/profile의 실제 값으로 표시. - "업무 Context와 권한"에서 서버에 대응 개념이 없던 담당 업무 영역·문서 열람 범위 항목은 제거하고, role 기반으로 실제로 검증되는 승인/자료 등록 권한만 표시. - 개인 알림 설정을 GET/PATCH /notifications/preferences에 연결 — 토글하면 실제로 저장되고 새로고침해도 유지된다. 라벨/설명 문구는 서버에 없는 UI 전용 카피라 profileData.ts에 유지. Co-Authored-By: Claude Sonnet 5 --- src/api/notificationPreferences.ts | 23 +++ src/api/profile.ts | 7 + src/pages/ProfilePage/ProfilePage.test.tsx | 36 +++- src/pages/ProfilePage/ProfilePage.tsx | 189 ++++++++++++++------- src/pages/ProfilePage/profileData.ts | 65 +++---- 5 files changed, 212 insertions(+), 108 deletions(-) create mode 100644 src/api/notificationPreferences.ts diff --git a/src/api/notificationPreferences.ts b/src/api/notificationPreferences.ts new file mode 100644 index 0000000..e680e9b --- /dev/null +++ b/src/api/notificationPreferences.ts @@ -0,0 +1,23 @@ +import { apiFetch } from './client' + +// fowoco/server NotificationPreferenceResponse +// (GET/PATCH /api/v1/notifications/preferences) 그대로. +export interface NotificationPreferenceResponse { + key: string + enabled: boolean + required: boolean +} + +export function fetchNotificationPreferences() { + return apiFetch('/notifications/preferences') +} + +export function updateNotificationPreference(key: string, enabled: boolean) { + return apiFetch( + `/notifications/preferences/${encodeURIComponent(key)}`, + { + method: 'PATCH', + body: JSON.stringify({ enabled }), + }, + ) +} diff --git a/src/api/profile.ts b/src/api/profile.ts index f6f1273..596718f 100644 --- a/src/api/profile.ts +++ b/src/api/profile.ts @@ -4,6 +4,13 @@ import { apiFetch } from './client' export interface ProfileResponse { display_name: string phone: string | null + role: 'ADMIN' | 'HR' | 'VIEWER' + account_status: 'ACTIVE' | 'SUSPENDED' | 'DISABLED' + password_changed_at: string + // 로그인 이력이 전혀 없을 수는 없지만(토큰 자체가 로그인으로만 발급됨) 서버 계약상 null 허용. + last_login_at: string | null + last_login_device: string | null + recent_device_count: number } export interface UpdateProfileRequest { diff --git a/src/pages/ProfilePage/ProfilePage.test.tsx b/src/pages/ProfilePage/ProfilePage.test.tsx index dfe2be2..ac5b6fe 100644 --- a/src/pages/ProfilePage/ProfilePage.test.tsx +++ b/src/pages/ProfilePage/ProfilePage.test.tsx @@ -8,7 +8,27 @@ import { useToastStore } from '../../store/toastStore' import { ProfilePage } from './ProfilePage' // fowoco/server ProfileResponse (GET/PATCH /api/v1/auth/me/profile) 그대로. -const PROFILE = { display_name: '김민지 HR', phone: '010-0000-1234' } +const PROFILE = { + display_name: '김민지 HR', + phone: '010-0000-1234', + role: 'HR', + account_status: 'ACTIVE', + password_changed_at: '2026-07-01T00:00:00Z', + last_login_at: '2026-08-14T00:12:00Z', + last_login_device: 'Chrome · macOS', + recent_device_count: 1, +} + +// fowoco/server NotificationPreferenceResponse[] (GET/PATCH /api/v1/notifications/preferences) 그대로. +const NOTIFICATION_PREFERENCES = [ + { key: 'security-permission', enabled: true, required: true }, + { key: 'approval-request', enabled: true, required: false }, + { key: 'document-submitted', enabled: true, required: false }, + { key: 'document-needs-fix', enabled: true, required: false }, + { key: 'due-soon', enabled: true, required: false }, + { key: 'assigned', enabled: false, required: false }, + { key: 'agent-ready', enabled: true, required: false }, +] const SETTINGS = { approval_policy: 'ADMIN_OR_HR', @@ -62,6 +82,14 @@ beforeEach(() => { return jsonResponse({ ...PROFILE, ...JSON.parse(init.body as string) }) } if (url.includes('/auth/me/profile')) return jsonResponse(PROFILE) + if (url.includes('/notifications/preferences') && init?.method === 'PATCH') { + const { enabled } = JSON.parse(init.body as string) + const key = url.split('/').pop() + return jsonResponse( + NOTIFICATION_PREFERENCES.map((pref) => (pref.key === key ? { ...pref, enabled } : pref)), + ) + } + if (url.includes('/notifications/preferences')) return jsonResponse(NOTIFICATION_PREFERENCES) return Promise.reject(new Error(`Unexpected request: ${url}`)) }), ) @@ -178,19 +206,19 @@ describe('ProfilePage', () => { const user = userEvent.setup() renderPage() - const toggle = screen.getByRole('switch', { name: '담당자 지정' }) + const toggle = await screen.findByRole('switch', { name: '담당자 지정' }) expect(toggle).toHaveAttribute('aria-checked', 'false') await user.click(toggle) - expect(toggle).toHaveAttribute('aria-checked', 'true') + await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'true')) }) it('shows the mandatory security notification as a disabled, always-on toggle', async () => { const user = userEvent.setup() renderPage() - const toggle = screen.getByRole('switch', { name: '보안·권한 변경 알림' }) + const toggle = await screen.findByRole('switch', { name: '보안·권한 변경 알림' }) expect(toggle).toHaveAttribute('aria-checked', 'true') expect(toggle).toBeDisabled() diff --git a/src/pages/ProfilePage/ProfilePage.tsx b/src/pages/ProfilePage/ProfilePage.tsx index 2d2b736..1fb1307 100644 --- a/src/pages/ProfilePage/ProfilePage.tsx +++ b/src/pages/ProfilePage/ProfilePage.tsx @@ -1,6 +1,11 @@ import { useNavigate, useBlocker } from 'react-router-dom' import { useCallback, useEffect, useState } from 'react' import { fetchMyProfile, updateMyProfile, type ProfileResponse } from '../../api/profile' +import { + fetchNotificationPreferences, + updateNotificationPreference, + type NotificationPreferenceResponse, +} from '../../api/notificationPreferences' import { ApiError, getErrorMessage } from '../../api/errors' import { Button } from '../../components/ui/Button/Button' import { DetailRow } from '../../components/ui/DetailRow/DetailRow' @@ -9,13 +14,9 @@ import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' import { useApiQuery } from '../../hooks/useApiQuery' import { useAuthStore } from '../../store/authStore' import { useToastStore } from '../../store/toastStore' +import { formatEventTime } from '../../utils/datetime' import { CompanySettingsPanel } from './CompanySettingsPanel' -import { - INITIAL_NOTIFICATION_PREFS, - PROFILE_SUMMARY, - SECURITY_INFO, - WORK_CONTEXT, -} from './profileData' +import { NOTIFICATION_PREFERENCE_COPY, PROFILE_SUMMARY } from './profileData' import styles from './ProfilePage.module.css' interface EditableFields { @@ -25,6 +26,26 @@ interface EditableFields { type FieldErrors = Partial> +const ROLE_LABEL: Record = { + ADMIN: '관리자', + HR: 'HR 담당자', + VIEWER: '조회 전용', +} + +const ACCOUNT_STATUS_LABEL: Record = { + ACTIVE: '정상', + SUSPENDED: '일시 정지', + DISABLED: '비활성화', +} + +function formatDateOnly(iso: string): string { + const date = new Date(iso) + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + return `${y}.${m}.${d}` +} + // Figma Screen Brief 04번 항목 기준 검증 규칙. function validateFields(input: EditableFields): FieldErrors { const errors: FieldErrors = {} @@ -56,6 +77,7 @@ export function ProfilePage() { const setStoreProfile = useAuthStore((state) => state.updateProfile) const { data: profile, status: profileStatus } = useApiQuery(fetchMyProfile) + const { data: preferenceData } = useApiQuery(fetchNotificationPreferences) const [fields, setFields] = useState(null) const [draft, setDraft] = useState(null) @@ -63,12 +85,16 @@ export function ProfilePage() { const [fieldErrors, setFieldErrors] = useState({}) const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) - const [notificationPrefs, setNotificationPrefs] = useState(INITIAL_NOTIFICATION_PREFS) + const [notificationPrefs, setNotificationPrefs] = useState([]) useEffect(() => { if (profile) setFields(toFields(profile)) }, [profile]) + useEffect(() => { + if (preferenceData) setNotificationPrefs(preferenceData) + }, [preferenceData]) + const changedFieldCount = editing && fields && draft ? (['displayName', 'phone'] as const).filter((key) => draft[key] !== fields[key]).length @@ -128,12 +154,23 @@ export function ProfilePage() { showToast('이메일 변경 요청을 관리자에게 전달했습니다.') } - function handleToggleNotification(id: string) { + async function handleToggleNotification(key: string) { + const current = notificationPrefs.find((pref) => pref.key === key) + if (!current || current.required) return + + const nextEnabled = !current.enabled setNotificationPrefs((prev) => - prev.map((pref) => - pref.id === id && !pref.required ? { ...pref, enabled: !pref.enabled } : pref, - ), + prev.map((pref) => (pref.key === key ? { ...pref, enabled: nextEnabled } : pref)), ) + try { + const updated = await updateNotificationPreference(key, nextEnabled) + setNotificationPrefs(updated) + } catch { + setNotificationPrefs((prev) => + prev.map((pref) => (pref.key === key ? { ...pref, enabled: current.enabled } : pref)), + ) + showToast('알림 설정을 저장하지 못했습니다.') + } } function handleBlockerContinueEditing() { @@ -193,8 +230,10 @@ export function ProfilePage() {

마지막 로그인

-

{PROFILE_SUMMARY.lastLoginAt}

-

{PROFILE_SUMMARY.lastLoginDevice}

+

+ {profile?.last_login_at ? formatEventTime(profile.last_login_at) : '확인 중…'} +

+

{profile?.last_login_device ?? ''}

@@ -291,27 +330,33 @@ export function ProfilePage() {

업무 Context와 권한

-

{WORK_CONTEXT.companySummary}

+

+ {(user?.workplace ?? PROFILE_SUMMARY.companyName) + + (profile ? ` · ${ROLE_LABEL[profile.role]}` : '')} +


- - {WORK_CONTEXT.canApprove ? '업무 승인 가능' : '승인 불가'} - - } - /> - - - {WORK_CONTEXT.canRegisterData ? '권한 있음' : '권한 없음'} - - } - /> - + {profile && ( + <> + + + {profile.role !== 'VIEWER' ? '업무 승인 가능' : '승인 불가'} + + } + /> + + {profile.role !== 'VIEWER' ? '권한 있음' : '권한 없음'} + + } + /> + + )}
@@ -324,29 +369,35 @@ export function ProfilePage() {
- {notificationPrefs.map((pref) => ( -
-
-

- {pref.label} - {pref.required && 필수} -

-

{pref.description}

+ {notificationPrefs.map((pref) => { + const copy = NOTIFICATION_PREFERENCE_COPY[pref.key] ?? { + label: pref.key, + description: '', + } + return ( +
+
+

+ {copy.label} + {pref.required && 필수} +

+

{copy.description}

+
+
- -
- ))} + ) + })}
@@ -355,12 +406,30 @@ export function ProfilePage() {

계정 보호 상태와 최근 로그인 정보를 확인합니다.


- {SECURITY_INFO.accountStatus}} - /> - - + {profile && ( + <> + + {ACCOUNT_STATUS_LABEL[profile.account_status]} + + } + /> + + + + )}