diff --git a/src/api/alerts.ts b/src/api/alerts.ts new file mode 100644 index 0000000..d6b34fb --- /dev/null +++ b/src/api/alerts.ts @@ -0,0 +1,30 @@ +// src/api/alerts.ts +import axios from '@/lib/axios'; +import type { ApiResponse, PaginatedResponse } from '@/types/api'; +import type { Notice } from '@/types/notice'; +import type { Shop } from '@/types/shop'; + +// 서버 응답에 맞춘 타입 +export type AlertItem = { + id: string; + createdAt: string; + result: 'accepted' | 'rejected'; + read: boolean; + application: { item: { id: string; status: 'pending' | 'accepted' | 'rejected' } }; + shop: { item: Shop }; + notice: { item: Notice }; +}; + +export async function getUserAlerts(userId: string, params?: { offset?: number; limit?: number }) { + const { data } = await axios.get[] }>( + `/users/${userId}/alerts`, + { params } + ); + return data; +} + +export async function markAlertRead(userId: string, alertId: string) { + // PUT /users/{user_id}/alerts/{alert_id} (Body 없이 호출해도 OK) + const { data } = await axios.put<{ item: AlertItem }>(`/users/${userId}/alerts/${alertId}`); + return data.item; +} diff --git a/src/components/layout/header/nav.tsx b/src/components/layout/header/nav.tsx index d81297f..49f0ea2 100644 --- a/src/components/layout/header/nav.tsx +++ b/src/components/layout/header/nav.tsx @@ -1,8 +1,11 @@ import { Icon } from '@/components/ui'; +import Notification, { type Alert } from '@/components/ui/modal/notification/Notification'; +import { useUserApplications } from '@/context/userApplicationsProvider'; import useAuth from '@/hooks/useAuth'; import { cn } from '@/lib/utils/cn'; import { UserRole } from '@/types/user'; import Link from 'next/link'; +import { useMemo, useState } from 'react'; interface NavItems { href: string; @@ -20,6 +23,37 @@ const NAV_ITEMS: Record = { const Nav = () => { const { role, isLogin, logout } = useAuth(); + const { applications } = useUserApplications(); + const [open, setOpen] = useState(false); + // 읽음 처리한 알림 ID들 (간단 로컬 상태) + const [readIds, setReadIds] = useState>(new Set()); + + // 알바님 알림: 승인/거절만 표시 + const alerts: Alert[] = useMemo(() => { + return applications + .filter(a => a.item.status !== 'pending') + .map(a => ({ + id: a.item.id, + createdAt: a.item.createdAt ?? new Date().toISOString(), + result: a.item.status === 'accepted' ? 'accepted' : 'rejected', + // ▶ 읽음: 사용자가 메시지를 클릭했을 때만 true + read: readIds.has(a.item.id), + shop: { item: a.item.shop.item, href: `/shops/${a.item.shop.item.id}` }, + notice: { item: a.item.notice.item, href: `/notices/${a.item.notice.item.id}` }, + })); + }, [applications, readIds]); + + const unreadCount = alerts.filter(a => !a.read).length; + const bellIcon: 'notificationOn' | 'notificationOff' = + unreadCount > 0 ? 'notificationOn' : 'notificationOff'; + + const handleRead = (id: string) => { + setReadIds(prev => { + const next = new Set(prev); + next.add(id); + return next; + }); + }; return ( diff --git a/src/components/ui/modal/notification/Notification.tsx b/src/components/ui/modal/notification/Notification.tsx index 1bef66d..42bded5 100644 --- a/src/components/ui/modal/notification/Notification.tsx +++ b/src/components/ui/modal/notification/Notification.tsx @@ -20,8 +20,11 @@ interface NotificationProps { onClose?: () => void; } -export default function Notification({ alerts, onRead }: NotificationProps) { - const [isOpen, setIsOpen] = useState(false); +export default function Notification({ alerts, onRead, isOpen, onClose }: NotificationProps) { + // 제어 모드인지 판별 + const controlled = typeof isOpen === 'boolean'; + const [internalOpen, setInternalOpen] = useState(false); + const open = controlled ? (isOpen as boolean) : internalOpen; const notificationCount = alerts.filter(alert => !alert.read).length; const SORTED_ALERTS = [...alerts].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() @@ -29,20 +32,23 @@ export default function Notification({ alerts, onRead }: NotificationProps) { return ( <> -
- -
- {isOpen && ( + {/* 제어 모드가 아니면 내부 트리거 버튼을 노출 */} + {!controlled && ( +
+ +
+ )} + {open && (
알림 {notificationCount}개
-
diff --git a/src/components/ui/modal/notification/NotificationMessage.tsx b/src/components/ui/modal/notification/NotificationMessage.tsx index a2283a8..d0e97a9 100644 --- a/src/components/ui/modal/notification/NotificationMessage.tsx +++ b/src/components/ui/modal/notification/NotificationMessage.tsx @@ -37,16 +37,16 @@ export default function NotificationMessage({

{`${shopName} (${DATE_RANGE.date} ${DATE_RANGE.startTime} ~ ${DATE_RANGE.endTime}) 공고 지원이 `} {RESULT_TEXT} diff --git a/src/components/ui/table/Table.tsx b/src/components/ui/table/Table.tsx index 6ccaedc..ad1016d 100644 --- a/src/components/ui/table/Table.tsx +++ b/src/components/ui/table/Table.tsx @@ -26,7 +26,9 @@ export default function Table({ return (

- {userRole === 'employer' ? '신청자 목록' : '신청 목록'} +

+ {userRole === 'employer' ? '신청자 목록' : '신청 내역'} +

diff --git a/src/pages/my-profile/index.tsx b/src/pages/my-profile/index.tsx index 517f1e7..bf67280 100644 --- a/src/pages/my-profile/index.tsx +++ b/src/pages/my-profile/index.tsx @@ -7,52 +7,59 @@ import Button from '@/components/ui/button/button'; import Table from '@/components/ui/table/Table'; import type { TableRowProps } from '@/components/ui/table/TableRowProps'; import { ICONS, ICON_SIZES } from '@/constants/icon'; +import { useUserApplications } from '@/context/userApplicationsProvider'; import useAuth from '@/hooks/useAuth'; -import type { UserType } from '@/types/user'; // 'employee' | 'employer' +import type { ApiResponse } from '@/types/api'; +import type { ApplicationItem } from '@/types/applications'; +import type { User, UserType } from '@/types/user'; export default function MyProfileDetailPage() { const { isLogin, user } = useAuth(); + const { applications, isLoading } = useUserApplications(); - // 임시: 신청 내역은 아직 서버 API가 없다면 로컬 모의 데이터 사용 - const [applications, setApplications] = useState([]); - const [isLoadingApps, setIsLoadingApps] = useState(true); - - // 페이지네이션 상태(팀 Table 시그니처 맞춤) + // 테이블 페이지네이션 const [offset, setOffset] = useState(0); - const limit = 10; - - // (모의) 저장 키 - 나중에 서버 API로 교체 가능 - const appsKey = useMemo(() => `thejulge_apps_${user?.id ?? 'guest'}`, [user?.id]); - - // 신청 내역 로드(모의) - useEffect(() => { - if (!isLogin) return; - setIsLoadingApps(true); - try { - const txt = localStorage.getItem(appsKey); - const parsed = txt ? (JSON.parse(txt) as TableRowProps[]) : []; - setApplications(parsed); - } catch { - setApplications([]); - } finally { - setIsLoadingApps(false); - } - }, [isLogin, appsKey]); - - // 프로필 비었는지 판단 (user 기준) - const name = user?.name ?? ''; - const phone = user?.phone ?? ''; - // 서버 필드명이 address라고 가정 - const address = (user?.address as string) ?? ''; - const bio = user?.bio ?? ''; - - const isProfileEmpty = !name && !phone && !address && !(bio && bio.trim()); + const limit = 5; + + // 프로필 비었는지 판단 (User | null 안전) + function isProfileEmpty(u: User | null): boolean { + const name = u?.name?.trim() ?? ''; + const phone = u?.phone?.trim() ?? ''; + const address = (u?.address as string | undefined)?.trim() ?? ''; + const bio = u?.bio?.trim() ?? ''; + return !name && !phone && !address && !bio; + } + const profileIsEmpty = useMemo(() => isProfileEmpty(user), [user]); const headers: string[] = ['가게명', '근무일시', '시급', '상태']; const userType: UserType = 'employee'; - // 현재 페이지 조각 - const paged = useMemo(() => applications.slice(offset, offset + limit), [applications, offset]); + // 서버 응답 → TableRowProps 매핑 + const rows: TableRowProps[] = useMemo(() => { + return applications.map((app: ApiResponse) => { + const a = app.item; + const status = + a.status === 'accepted' ? 'approved' : a.status === 'rejected' ? 'rejected' : 'pending'; + return { + id: a.id, + name: a.shop.item.name, + hourlyPay: `${a.notice.item.hourlyPay.toLocaleString()}원`, + startsAt: a.notice.item.startsAt, + workhour: a.notice.item.workhour, + status, + // employee 표에서는 미사용 — 타입만 충족 + bio: '', + phone: '', + }; + }); + }, [applications]); + + const pagedRows = useMemo(() => rows.slice(offset, offset + limit), [rows, offset]); + + // rows 변화 시 첫 페이지로 리셋 (페이지네이션 UX 보강) + useEffect(() => { + setOffset(0); + }, [rows.length]); return (
@@ -60,9 +67,9 @@ export default function MyProfileDetailPage() {

내 프로필

{/* 프로필이 없으면 등록 프레임 */} - {isProfileEmpty ? ( + {profileIsEmpty ? (

이름

- {name || '—'} + {user?.name || '—'}

{/* 연락처 */} @@ -90,7 +97,7 @@ export default function MyProfileDetailPage() { className={ICON_SIZES.md} priority /> - {phone || '—'} + {user?.phone || '—'}
{/* 선호 지역 */} @@ -103,13 +110,13 @@ export default function MyProfileDetailPage() { className={ICON_SIZES.md} priority /> - 선호 지역: {address || '—'} + 선호 지역: {(user?.address as string) || '—'}
{/* 소개 */} - {bio && ( + {user?.bio && (

- {bio} + {user.bio}

)}
@@ -131,12 +138,12 @@ export default function MyProfileDetailPage() { )}
- {/* 신청 내역 — 프로필 있을 때만 노출 */} - {!isProfileEmpty && ( + {/* 신청 내역 — 프로필 있고 로그인 상태일 때만 */} + {!profileIsEmpty && isLogin && (
- {isLoadingApps ? ( + {isLoading ? (
불러오는 중…
- ) : applications.length === 0 ? ( + ) : rows.length === 0 ? (
) : (
-

신청 내역

- {/* 팀 Table이 요구하는 pagination props 전달 */} setOffset(p*limit) 로 바꾸세요. + onPageChange={setOffset} /> )}