Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/api/alerts.ts
Original file line number Diff line number Diff line change
@@ -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<PaginatedResponse & { items: ApiResponse<AlertItem>[] }>(
`/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;
}
46 changes: 44 additions & 2 deletions src/components/layout/header/nav.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,6 +23,37 @@ const NAV_ITEMS: Record<UserRole, NavItems[]> = {

const Nav = () => {
const { role, isLogin, logout } = useAuth();
const { applications } = useUserApplications();
const [open, setOpen] = useState(false);
// 읽음 처리한 알림 ID들 (간단 로컬 상태)
const [readIds, setReadIds] = useState<Set<string>>(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 (
<nav className={cn('flex shrink-0 items-center gap-4 text-body-m font-bold', 'desktop:gap-10')}>
Expand All @@ -40,9 +74,17 @@ const Nav = () => {
>
로그아웃
</button>
<button type='button' aria-label='알림 확인하기'>
<Icon iconName='notificationOff' iconSize='rg' bigScreenSize='md' ariaLabel='알림' />
<button type='button' aria-label='알림 확인하기' onClick={() => setOpen(true)}>
<Icon iconName={bellIcon} iconSize='rg' bigScreenSize='md' ariaLabel='알림' />
</button>
<div className='absolute right-4 top-[64px] z-[50] w-full max-w-[420px]'>
<Notification
alerts={alerts}
onRead={handleRead}
isOpen={open}
onClose={() => setOpen(false)}
/>
</div>
</>
)}
</nav>
Expand Down
30 changes: 18 additions & 12 deletions src/components/ui/modal/notification/Notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,35 @@ 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()
);

return (
<>
<div className='relative flex justify-end'>
<button
onClick={() => setIsOpen(!isOpen)}
className={`${isOpen ? 'hidden' : 'block'} relative md:block`}
>
<Icon iconName='notificationOn' iconSize='sm' ariaLabel='알림' />
</button>
</div>
{isOpen && (
{/* 제어 모드가 아니면 내부 트리거 버튼을 노출 */}
{!controlled && (
<div className='relative flex justify-end'>
<button
onClick={() => setInternalOpen(v => !v)}
className={`${open ? 'hidden' : 'block'} relative md:block`}
>
<Icon iconName='notificationOn' iconSize='sm' ariaLabel='알림' />
</button>
</div>
)}
{open && (
<div className='flex min-h-screen flex-col gap-4 bg-red-100 px-5 py-10'>
<div className='flex justify-between'>
<div className='text-[20px] font-bold'>알림 {notificationCount}개</div>
<div>
<button onClick={() => setIsOpen(false)}>
<button onClick={() => (controlled ? onClose?.() : setInternalOpen(false))}>
<Icon iconName='close' iconSize='lg' ariaLabel='닫기' />
</button>
</div>
Expand Down
8 changes: 4 additions & 4 deletions src/components/ui/modal/notification/NotificationMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,16 @@ export default function NotificationMessage({
<ResultBadge result={result} />
<p
className={clsx('text-sm', {
'text-gray-400': read,
'text-[var(--gray-400)]': read,
})}
>
{`${shopName} (${DATE_RANGE.date} ${DATE_RANGE.startTime} ~
${DATE_RANGE.endTime}) 공고 지원이 `}
<span
className={clsx({
'text-gray-500': read,
'text-blue-200': !read && result === 'accepted',
'text-red-400': !read && result === 'rejected',
'text-[var(--gray-500)]': read,
'text-[var(--blue-200)]': !read && result === 'accepted',
'text-[var(--red-500)]': !read && result === 'rejected',
Comment on lines +40 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 컴포넌트의 컬러를 var-- 형태로 변환하신 이유가 있으실까요 ?
왜냐면 기존과 동일한 문법인데 바꾸신 이유가 있을까 해서요!

})}
>
{RESULT_TEXT}
Expand Down
4 changes: 3 additions & 1 deletion src/components/ui/table/Table.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

table 컴포넌트를 인화님도 수정하신 상태여서 PR merge 후 재 push 부탁드립니다

Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export default function Table({
return (
<div className='py-[60px]'>
<div className='px-8 text-xl font-bold md:px-10 lg:mx-auto lg:max-w-[1000px] lg:px-0'>
{userRole === 'employer' ? '신청자 목록' : '신청 목록'}
<h2 className='text-heading-l font-semibold'>
{userRole === 'employer' ? '신청자 목록' : '신청 내역'}
</h2>
</div>
<div className='m-7 overflow-hidden rounded-lg border bg-white lg:mx-auto lg:max-w-[1000px]'>
<div className='scroll-bar overflow-x-auto'>
Expand Down
107 changes: 56 additions & 51 deletions src/pages/my-profile/index.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 페이지에서는 디스코드에서 나왔던 유저 권한에 따른 리다이렉트 화면이 필요해보입니다
모달 + 권한에따른 리다이렉트 상세내용은 디스코드에 작성되어있습니다!

Original file line number Diff line number Diff line change
Expand Up @@ -7,62 +7,69 @@ 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<TableRowProps[]>([]);
const [isLoadingApps, setIsLoadingApps] = useState<boolean>(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<ApplicationItem>) => {
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 (
<main className='mx-auto w-full max-w-[1440px] px-4 py-6 tablet:py-8'>
<div className='mx-auto w-full desktop:max-w-[957px]'>
<h1 className='mb-6 text-heading-l font-semibold'>내 프로필</h1>

{/* 프로필이 없으면 등록 프레임 */}
{isProfileEmpty ? (
{profileIsEmpty ? (
<Frame
title='내 프로필'
title=''
content='내 프로필을 등록하고 원하는 가게에 지원해 보세요.'
buttonText='내 프로필 등록하기'
href='/my-profile/register'
Expand All @@ -77,7 +84,7 @@ export default function MyProfileDetailPage() {
<div className='flex-1'>
<p className='mb-1 text-body-m font-semibold text-[var(--red-500)]'>이름</p>
<p className='text-heading-m font-extrabold leading-tight text-[var(--gray-900)]'>
{name || '—'}
{user?.name || '—'}
</p>

{/* 연락처 */}
Expand All @@ -90,7 +97,7 @@ export default function MyProfileDetailPage() {
className={ICON_SIZES.md}
priority
/>
<span className='text-body-m'>{phone || '—'}</span>
<span className='text-body-m'>{user?.phone || '—'}</span>
</div>

{/* 선호 지역 */}
Expand All @@ -103,13 +110,13 @@ export default function MyProfileDetailPage() {
className={ICON_SIZES.md}
priority
/>
<span className='text-body-m'>선호 지역: {address || '—'}</span>
<span className='text-body-m'>선호 지역: {(user?.address as string) || '—'}</span>
</div>

{/* 소개 */}
{bio && (
{user?.bio && (
<p className='mt-6 whitespace-pre-wrap text-body-m text-[var(--gray-900)]'>
{bio}
{user.bio}
</p>
)}
</div>
Expand All @@ -131,12 +138,12 @@ export default function MyProfileDetailPage() {
)}
</div>

{/* 신청 내역 — 프로필 있을 때만 노출 */}
{!isProfileEmpty && (
{/* 신청 내역 — 프로필 있고 로그인 상태일 때만 */}
{!profileIsEmpty && isLogin && (
<section className='mt-8'>
{isLoadingApps ? (
{isLoading ? (
<div className='text-body-m text-[var(--gray-500)]'>불러오는 중…</div>
) : applications.length === 0 ? (
) : rows.length === 0 ? (
<div className='mx-auto w-full desktop:max-w-[964px]'>
<Frame
title='신청 내역'
Expand All @@ -147,16 +154,14 @@ export default function MyProfileDetailPage() {
</div>
) : (
<div className='mx-auto w-full desktop:max-w-[964px]'>
<h2 className='mb-4 text-heading-s font-semibold'>신청 내역</h2>
{/* 팀 Table이 요구하는 pagination props 전달 */}
<Table
headers={headers}
data={paged}
tableData={pagedRows}
userRole={userType}
total={applications.length}
limit={limit}
offset={offset}
onPageChange={setOffset} // 팀 Table이 pageIndex를 요구하면 (p)=>setOffset(p*limit) 로 바꾸세요.
onPageChange={setOffset}
/>
</div>
)}
Expand Down