-
알림 {notificationCount}개
-
-
-
+
+
+
+
알림 {notificationCount}개
+
+
-
- {SORTED_ALERTS.length === 0 ? (
-
- ) : (
-
- {SORTED_ALERTS.map(alert => (
-
- ))}
-
- )}
- )}
+
+ {SORTED_ALERTS.length === 0 ? (
+
+ ) : (
+
+ {SORTED_ALERTS.map(alert => (
+
+ ))}
+
+ )}
+
>
);
}
diff --git a/src/components/ui/modal/notification/NotificationMessage.tsx b/src/components/ui/modal/notification/NotificationMessage.tsx
index d0e97a9..c36e517 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/modal/notification/ResultBadge.tsx b/src/components/ui/modal/notification/ResultBadge.tsx
index 9ba4d06..df64c37 100644
--- a/src/components/ui/modal/notification/ResultBadge.tsx
+++ b/src/components/ui/modal/notification/ResultBadge.tsx
@@ -1,19 +1,26 @@
import Icon from '@/components/ui/icon/icon';
export interface ResultBadgeProps {
- result: 'accepted' | 'rejected';
+ result: 'accepted' | 'rejected' | null;
}
-const ICON_COLORS: Record = {
+
+// null은 객체에서 빼고, default 색상을 따로 둔다
+const ICON_COLORS: Record<'accepted' | 'rejected', string> = {
accepted: 'bg-blue-200',
rejected: 'bg-red-400',
};
+const DEFAULT_COLOR = 'bg-gray-300';
export default function ResultBadge({ result }: ResultBadgeProps) {
+ const color = result ? ICON_COLORS[result] : DEFAULT_COLOR;
+
return (
);
}
diff --git a/src/components/ui/table/Table.stories.tsx b/src/components/ui/table/Table.stories.tsx
index 04c4e95..f9f139b 100644
--- a/src/components/ui/table/Table.stories.tsx
+++ b/src/components/ui/table/Table.stories.tsx
@@ -176,6 +176,9 @@ function TableWithTestApi({ userRole }: { userRole: UserRole }) {
limit={limit}
offset={offset}
onPageChange={setOffset}
+ onStatusUpdate={() => {}}
+ shopId=''
+ noticeId=''
/>
);
}
diff --git a/src/components/ui/table/Table.tsx b/src/components/ui/table/Table.tsx
index ad1016d..c0a3a61 100644
--- a/src/components/ui/table/Table.tsx
+++ b/src/components/ui/table/Table.tsx
@@ -1,4 +1,5 @@
import { Pagination } from '@/components/ui';
+import { StatusType } from '@/components/ui/badge/StatusBadge';
import { TableRowProps } from '@/components/ui/table/TableRowProps';
import { cn } from '@/lib/utils/cn';
import { UserRole } from '@/types/user';
@@ -12,6 +13,11 @@ interface TableProps {
limit: number;
offset: number;
onPageChange: (newOffset: number) => void;
+ onStatusUpdate: (id: string, newStatus: StatusType, shopId?: string, noticeId?: string) => void;
+ shopId?: string;
+ noticeId?: string;
+ applyNotice?: (shopId: string, noticeId: string) => void;
+ cancelNotice?: (shopId: string, noticeId: string) => void;
}
export default function Table({
@@ -22,6 +28,9 @@ export default function Table({
limit,
offset,
onPageChange,
+ onStatusUpdate,
+ shopId,
+ noticeId,
}: TableProps) {
return (
@@ -53,7 +62,15 @@ export default function Table({
{tableData.map(row => (
-
+
))}
diff --git a/src/components/ui/table/TableRow.tsx b/src/components/ui/table/TableRow.tsx
index af17530..7e80861 100644
--- a/src/components/ui/table/TableRow.tsx
+++ b/src/components/ui/table/TableRow.tsx
@@ -1,6 +1,9 @@
import { StatusBadge } from '@/components/ui/badge';
import { StatusType } from '@/components/ui/badge/StatusBadge';
+import { Button } from '@/components/ui/button';
+import { Modal } from '@/components/ui/modal';
import { TableRowProps } from '@/components/ui/table/TableRowProps';
+import axiosInstance from '@/lib/axios';
import { cn } from '@/lib/utils/cn';
import { getTime } from '@/lib/utils/dateFormatter';
import { UserRole } from '@/types/user';
@@ -9,47 +12,104 @@ import { useState } from 'react';
interface TableTypeVariant {
rowData: TableRowProps;
userRole: UserRole;
+ onStatusUpdate: (id: string, newStatus: StatusType) => void;
+ shopId?: string;
+ noticeId?: string;
}
const TD_BASE = 'border-b border-r px-3 py-5 text-base gap-3 md:border-r-0';
const TD_STATUS = 'border-b px-2 py-[9px]';
-export default function TableRow({ rowData, userRole: userRole }: TableTypeVariant) {
+export default function TableRow({ rowData, userRole, onStatusUpdate }: TableTypeVariant) {
const { date, startTime, endTime, duration } = getTime(rowData.startsAt, rowData.workhour);
const [status, setStatus] = useState
(rowData.status as StatusType);
- const handleStatusChange = (id: string, newStatus: StatusType) => {
- setStatus(newStatus);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [modalAction, setModalAction] = useState(null);
+
+ const handleClick = (action: StatusType) => {
+ setModalAction(action);
+ setModalOpen(true);
};
- const handleApprove = () => setStatus('accepted');
- const handleReject = () => setStatus('rejected');
+ if (!rowData.shopId || !rowData.noticeId) {
+ alert('잘못된 신청 정보입니다.');
+ return;
+ }
+
+ const handleStatusChange = async () => {
+ if (!modalAction) return;
+
+ try {
+ await axiosInstance.put(
+ `/shops/${rowData.shopId}/notices/${rowData.noticeId}/applications/${rowData.id}`,
+ { status: modalAction }
+ );
+
+ setStatus(modalAction);
+
+ onStatusUpdate(rowData.id, modalAction);
+ } catch (error) {
+ alert(error instanceof Error ? error.message : '상태 변경 실패');
+ } finally {
+ setModalOpen(false);
+ setModalAction(null);
+ }
+ };
return (
-
- | {rowData.name} |
-
- {userRole === 'employee' ? (
- <>
- {`${date} ${startTime} ~ ${date} ${endTime} (${duration})`} |
- {rowData.hourlyPay} |
- >
- ) : (
- <>
- {rowData.bio} |
- {rowData.phone} |
- >
- )}
-
-
- |
-
+ <>
+
+ | {rowData.name} |
+
+ {userRole === 'employee' ? (
+ <>
+ {`${date} ${startTime} ~ ${date} ${endTime} (${duration})`} |
+ {rowData.hourlyPay} |
+ >
+ ) : (
+ <>
+ {rowData.bio} |
+ {rowData.phone} |
+ >
+ )}
+
+
+ {status === 'pending' && userRole === 'employer' ? (
+
+
+
+
+ ) : (
+
+ )}
+ |
+
+
+ setModalOpen(false)}
+ variant='warning'
+ title={`신청을 ${modalAction === 'accepted' ? '승인' : '거절'}하시겠어요?`}
+ primaryText='확인'
+ secondaryText='취소'
+ onPrimary={handleStatusChange}
+ onSecondary={() => setModalOpen(false)}
+ />
+ >
);
}
diff --git a/src/components/ui/table/TableRowProps.tsx b/src/components/ui/table/TableRowProps.tsx
index 5375c61..6205358 100644
--- a/src/components/ui/table/TableRowProps.tsx
+++ b/src/components/ui/table/TableRowProps.tsx
@@ -7,4 +7,7 @@ export type TableRowProps = {
status: string | JSX.Element;
bio: string;
phone: string;
+ userId?: string;
+ shopId: string;
+ noticeId: string;
};
diff --git a/src/context/notificationContext/index.tsx b/src/context/notificationContext/index.tsx
new file mode 100644
index 0000000..dc30b3c
--- /dev/null
+++ b/src/context/notificationContext/index.tsx
@@ -0,0 +1,42 @@
+import { Alert } from '@/components/ui/modal/notification/Notification';
+import axiosInstance from '@/lib/axios';
+import { createContext, ReactNode, useContext, useState } from 'react';
+
+interface NotificationContextType {
+ alerts: Alert[];
+ fetchAlerts: () => Promise;
+ addAlert: (alert: Alert) => void;
+ markAsRead: (id: string) => void;
+}
+
+const NotificationContext = createContext(undefined);
+
+export const NotificationProvider = ({ children }: { children: ReactNode }) => {
+ const [alerts, setAlerts] = useState([]);
+
+ const fetchAlerts = async () => {
+ const res = await axiosInstance.get('/users/me/alerts');
+ setAlerts(res.data);
+ };
+
+ const addAlert = (alert: Alert) => {
+ setAlerts(prev => [alert, ...prev]);
+ };
+
+ const markAsRead = (id: string) => {
+ setAlerts(prev => prev.map(a => (a.id === id ? { ...a, read: true } : a)));
+ axiosInstance.put(`/users/me/alerts/${id}`); // 서버에도 반영
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useNotification = () => {
+ const context = useContext(NotificationContext);
+ if (!context) throw new Error('useNotification must be used within NotificationProvider');
+ return context;
+};
diff --git a/src/context/userApplicationsProvider.tsx b/src/context/userApplicationsProvider.tsx
index 157f930..2d0dad1 100644
--- a/src/context/userApplicationsProvider.tsx
+++ b/src/context/userApplicationsProvider.tsx
@@ -81,8 +81,13 @@ export const UserApplicationsProvider = ({ children }: { children: ReactNode })
setError('로그인이 필요합니다.');
return;
}
- await postApplication(shopId, noticeId);
- await fetchAllApplications(); // 최신화 반영
+
+ try {
+ await postApplication(shopId, noticeId);
+ await fetchAllApplications(); // 최신화
+ } catch {
+ setError('신청 중 오류가 발생했습니다.');
+ }
},
[user, fetchAllApplications]
);
diff --git a/src/pages/employer/shops/[shopId]/notices/[noticeId]/edit.tsx b/src/pages/employer/shops/[shopId]/notices/[noticeId]/edit.tsx
index 9917ef7..2d0c694 100644
--- a/src/pages/employer/shops/[shopId]/notices/[noticeId]/edit.tsx
+++ b/src/pages/employer/shops/[shopId]/notices/[noticeId]/edit.tsx
@@ -1,6 +1,8 @@
+import { Container, Wrapper } from '@/components/layout';
import { Button, DateInput, Input, Modal, TimeInput } from '@/components/ui';
import useAuth from '@/hooks/useAuth';
import axiosInstance from '@/lib/axios';
+import { TimeValue } from '@/types/calendar';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
@@ -18,7 +20,7 @@ const EmployerNoticeEditPage = () => {
const [wage, setWage] = useState('');
const [date, setDate] = useState(null);
- const [time, setTime] = useState(null);
+ const [time, setTime] = useState(null);
const [workhour, setWorkhour] = useState();
const [description, setDescription] = useState('');
@@ -45,8 +47,11 @@ const EmployerNoticeEditPage = () => {
setDescription(notice.description);
const startDate = new Date(notice.startsAt);
+ setTime({
+ date: startDate,
+ period: startDate.getHours() >= 12 ? '오후' : '오전',
+ });
setDate(startDate);
- setTime(startDate);
} catch {
alert('공고 정보를 불러오는 중 오류가 발생했습니다.');
router.back();
@@ -65,7 +70,7 @@ const EmployerNoticeEditPage = () => {
if (!user?.shop || !noticeId) return;
const combinedDateTime = new Date(date);
- combinedDateTime.setHours(time.getHours(), time.getMinutes(), 0, 0);
+ combinedDateTime.setHours(time.date.getHours(), time.date.getMinutes(), 0, 0);
const payload: NoticePayload = {
hourlyPay: Number(wage),
@@ -93,85 +98,82 @@ const EmployerNoticeEditPage = () => {
if (!user?.shop) return null;
return (
-
+
+
+ 공고 편집
+
+
+
+
+
+
);
};
diff --git a/src/pages/employer/shops/[shopId]/notices/[noticeId]/index.tsx b/src/pages/employer/shops/[shopId]/notices/[noticeId]/index.tsx
index 7e384db..38bae19 100644
--- a/src/pages/employer/shops/[shopId]/notices/[noticeId]/index.tsx
+++ b/src/pages/employer/shops/[shopId]/notices/[noticeId]/index.tsx
@@ -1,3 +1,4 @@
+import { Container, Wrapper } from '@/components/layout';
import { Button, Modal, Notice, Table } from '@/components/ui';
import { TableRowProps } from '@/components/ui/table/TableRowProps';
import useAuth from '@/hooks/useAuth';
@@ -96,7 +97,6 @@ export const getServerSideProps: GetServerSideProps<{ notice: NoticeCard }> = as
const EmployerNoticeDetailPage = ({ notice }: { notice: NoticeCard }) => {
const headers = ['신청자', '소개', '전화번호', '상태'];
const [data, setData] = useState([]);
-
const [offset, setOffset] = useState(0);
const limit = 5;
@@ -173,6 +173,9 @@ const EmployerNoticeDetailPage = ({ notice }: { notice: NoticeCard }) => {
? `${noticeItem.hourlyPay.toLocaleString()}원`
: '정보 없음',
status: app.item.status,
+ userId: userItem?.id,
+ shopId: notice.shopId,
+ noticeId: notice.id,
};
});
@@ -183,39 +186,50 @@ const EmployerNoticeDetailPage = ({ notice }: { notice: NoticeCard }) => {
}, [notice.shopId, notice.id, offset, limit]);
return (
-
-
-
- setModalOpen(false)}
- variant='warning'
- title={modal?.title ?? '유저 정보를 확인해주세요'}
- primaryText={modal?.primaryText ?? '확인'}
- onPrimary={modal?.onPrimary ?? (() => setModalOpen(false))}
- secondaryText={modal?.secondaryText}
- onSecondary={modal?.onSecondary}
- />
-
-
-
+
+
+
+
+
+ setModalOpen(false)}
+ variant='warning'
+ title={modal?.title ?? '유저 정보를 확인해주세요'}
+ primaryText={modal?.primaryText ?? '확인'}
+ onPrimary={modal?.onPrimary ?? (() => setModalOpen(false))}
+ secondaryText={modal?.secondaryText}
+ onSecondary={modal?.onSecondary}
+ />
+
+
+ setData(prev =>
+ prev.map(row => (row.id === id ? { ...row, status: newStatus } : row))
+ )
+ }
+ shopId={notice.shopId}
+ noticeId={notice.id}
+ />
+
+
+
);
};
diff --git a/src/pages/employer/shops/[shopId]/notices/register/index.tsx b/src/pages/employer/shops/[shopId]/notices/register/index.tsx
index 3ec25b4..d4d98eb 100644
--- a/src/pages/employer/shops/[shopId]/notices/register/index.tsx
+++ b/src/pages/employer/shops/[shopId]/notices/register/index.tsx
@@ -1,14 +1,19 @@
+import { Container, Wrapper } from '@/components/layout';
import { Button, DateInput, Input, Modal, TimeInput } from '@/components/ui';
import useAuth from '@/hooks/useAuth';
import axiosInstance from '@/lib/axios';
+import { cn } from '@/lib/utils/cn';
+import { TimeValue } from '@/types/calendar';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
+
interface NoticeLoad {
hourlyPay: number;
startsAt: string;
workhour: number;
description: string;
}
+
const EmployerNoticeRegisterPage = () => {
const router = useRouter();
const { user } = useAuth();
@@ -19,6 +24,7 @@ const EmployerNoticeRegisterPage = () => {
const [workhour, setWorkhour] = useState();
const [description, setDescription] = useState('');
+ const [pastTimeModal, setPastTimeModal] = useState(false);
const [accessModal, setAccessModal] = useState(false);
const [successModal, setSuccessModal] = useState(false);
const [modalHandler, setModalHandler] = useState<() => void>(() => () => {});
@@ -33,9 +39,31 @@ const EmployerNoticeRegisterPage = () => {
e.preventDefault();
if (!date || !time || !wage || !workhour || !description) return;
if (!user?.shop) return;
- const combinedDateTime = new Date(date);
- combinedDateTime.setHours(time.getHours(), time.getMinutes(), 0, 0);
- const payload: NoticeLoad = {
+
+ const now = new Date();
+ const period = time.getHours() >= 12 ? '오후' : '오전';
+ const hours24 =
+ period === '오후' && time!.getHours() !== 12
+ ? time!.getHours() + 12
+ : period === '오전' && time!.getHours() === 12
+ ? 0
+ : time!.getHours();
+
+ const combinedDateTime = new Date(
+ date.getFullYear(),
+ date.getMonth(),
+ date.getDate(),
+ hours24,
+ time.getMinutes(),
+ 0
+ );
+
+ if (combinedDateTime < now) {
+ setPastTimeModal(true);
+ return;
+ }
+
+ const noticeLoad: NoticeLoad = {
hourlyPay: Number(wage),
startsAt: combinedDateTime.toISOString(),
workhour,
@@ -43,7 +71,7 @@ const EmployerNoticeRegisterPage = () => {
};
try {
- const response = await axiosInstance.post(`/shops/${user.shop.item.id}/notices`, payload);
+ const response = await axiosInstance.post(`/shops/${user.shop.item.id}/notices`, noticeLoad);
const noticeId = response.data.item.id;
if (!noticeId) {
@@ -64,98 +92,118 @@ const EmployerNoticeRegisterPage = () => {
};
return (
-
-
공고 등록
-
-
- {accessModal && (
+ )}
+
setSuccessModal(false)}
+ title='등록 완료'
+ variant='success'
primaryText='확인'
- onPrimary={() => router.replace('/')} // 확인 누르면 메인으로
- onClose={() => setAccessModal(false)}
+ onPrimary={modalHandler}
/>
- )}
-
- setSuccessModal(false)}
- title='등록 완료'
- variant='success'
- primaryText='확인'
- onPrimary={modalHandler}
- />
-
+
+
);
};
+
export default EmployerNoticeRegisterPage;
diff --git a/src/pages/my-profile/index.tsx b/src/pages/my-profile/index.tsx
index b28a440..afe28f2 100644
--- a/src/pages/my-profile/index.tsx
+++ b/src/pages/my-profile/index.tsx
@@ -2,6 +2,7 @@
import Image from 'next/image';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
+
import Frame from '@/components/layout/frame/frame';
import Button from '@/components/ui/button/button';
import Table from '@/components/ui/table/Table';
@@ -21,6 +22,8 @@ export default function MyProfileDetailPage() {
const [offset, setOffset] = useState(0);
const limit = 5;
+ const [tableRows, setTableRows] = useState([]);
+
// 프로필 비었는지 판단 (User | null 안전)
function isProfileEmpty(u: User | null): boolean {
const name = u?.name?.trim() ?? '';
@@ -49,6 +52,8 @@ export default function MyProfileDetailPage() {
status,
bio: '',
phone: '',
+ shopId: a.shop.item.id,
+ noticeId: a.notice.item.id,
};
});
}, [applications]);
@@ -73,6 +78,28 @@ export default function MyProfileDetailPage() {
const pagedRows = useMemo(() => currentRows.slice(offset, offset + limit), [currentRows, offset]);
+ useEffect(() => {
+ const mappedRows: TableRowProps[] = applications.map(app => {
+ 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,
+ bio: '',
+ phone: '',
+ shopId: a.shop.item.id,
+ noticeId: a.notice.item.id,
+ };
+ });
+
+ setTableRows(mappedRows);
+ }, [applications]);
+
return (
{/* 공통 컨테이너: Table과 좌측선/폭 동일 */}
@@ -186,15 +213,20 @@ export default function MyProfileDetailPage() {
href='/notices'
/>
) : (
-
+
+ setTableRows(prev =>
+ prev.map(row => (row.id === id ? { ...row, status: newStatus } : row))
+ )
+ }
/>
)}
diff --git a/src/pages/notices/[shopId]/[noticeId]/index.tsx b/src/pages/notices/[shopId]/[noticeId]/index.tsx
index 9adae13..3b5582f 100644
--- a/src/pages/notices/[shopId]/[noticeId]/index.tsx
+++ b/src/pages/notices/[shopId]/[noticeId]/index.tsx
@@ -85,7 +85,7 @@ export const getServerSideProps: GetServerSideProps<{ notice: NoticeCard }> = as
const NoticeDetail = ({ notice }: { notice: NoticeCard }) => {
const { role, isLogin, user } = useAuth();
- const { isApplied, applyNotice, cancelNotice, error } = useUserApplications();
+ const { isApplied, applyNotice, cancelNotice, error, refresh } = useUserApplications();
const { showToast } = useToast();
const { handleRecentNotice } = useRecentNotice(notice);
const router = useRouter();
@@ -95,6 +95,8 @@ const NoticeDetail = ({ notice }: { notice: NoticeCard }) => {
const status = getNoticeStatus(notice.closed, notice.startsAt);
const canApply = useMemo(() => status === 'open', [status]);
+ const applied = isApplied(notice.id);
+
// 공고 지원하기
const handleApplyClick = useCallback(async () => {
if (!canApply) return; // 지난공고 , 공고마감 무시
@@ -137,7 +139,7 @@ const NoticeDetail = ({ notice }: { notice: NoticeCard }) => {
// 기존 신청 여부 확인
// 이미 신청된 상태 -> 취소 여부 모달
- if (isApplied(notice.id)) {
+ if (applied) {
const items = APPLY_ITEMS.employee.cancel;
setModal({
...items,
@@ -146,6 +148,7 @@ const NoticeDetail = ({ notice }: { notice: NoticeCard }) => {
try {
await cancelNotice(notice.id);
showToast('신청이 취소되었습니다.');
+ await refresh();
} catch {
showToast(error ?? '신청 취소 중 오류가 발생했습니다.');
} finally {
@@ -178,13 +181,26 @@ const NoticeDetail = ({ notice }: { notice: NoticeCard }) => {
// isApplied는 내부에서 applications에만 의존하므로 배열에 제외
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [canApply, isLogin, role, user, notice, router, applyNotice, cancelNotice, showToast, error]);
+ }, [
+ canApply,
+ isLogin,
+ role,
+ user,
+ notice,
+ router,
+ applyNotice,
+ cancelNotice,
+ showToast,
+ error,
+ refresh,
+ ]);
// 최근 본 공고
useEffect(() => {
handleRecentNotice();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+
return (
diff --git a/src/types/calendar.ts b/src/types/calendar.ts
index e009fc0..9bff3f4 100644
--- a/src/types/calendar.ts
+++ b/src/types/calendar.ts
@@ -42,6 +42,11 @@ export type CalendarDay = {
// Time Selector 관련
export type Period = '오전' | '오후';
+export interface TimeValue {
+ date: Date;
+ period: Period;
+}
+
export interface TimeSelectorProps {
value?: string;
period: Period;