From 70d49eb134e7119b1fcc3a6f3a6ef5f49dbd21e4 Mon Sep 17 00:00:00 2001 From: gummmmmy0v0 Date: Mon, 20 Oct 2025 08:58:27 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=94=A7=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/applications.ts | 3 +- src/components/ui/badge/StatusBadge.tsx | 27 +-- .../ui/badge/statusbadge.stories.tsx | 2 +- src/components/ui/input/TimeInput.tsx | 127 +++++----- .../ui/modal/notification/Notification.tsx | 60 +++-- .../notification/NotificationMessage.tsx | 8 +- .../ui/modal/notification/ResultBadge.tsx | 15 +- src/components/ui/table/Table.stories.tsx | 3 + src/components/ui/table/Table.tsx | 19 +- src/components/ui/table/TableRow.tsx | 120 ++++++--- src/components/ui/table/TableRowProps.tsx | 3 + src/context/notificationContext/index.tsx | 42 ++++ src/context/userApplicationsProvider.tsx | 9 +- .../[shopId]/notices/[noticeId]/edit.tsx | 166 ++++++------- .../[shopId]/notices/[noticeId]/index.tsx | 82 ++++--- .../shops/[shopId]/notices/register/index.tsx | 228 +++++++++++------- src/pages/my-profile/index.tsx | 36 ++- .../notices/[shopId]/[noticeId]/index.tsx | 22 +- src/types/calendar.ts | 5 + 19 files changed, 610 insertions(+), 367 deletions(-) create mode 100644 src/context/notificationContext/index.tsx diff --git a/src/api/applications.ts b/src/api/applications.ts index aaf4d33..7ba0bf8 100644 --- a/src/api/applications.ts +++ b/src/api/applications.ts @@ -31,7 +31,8 @@ export async function getAllUserApplications({ // 가게의 특정 공고 지원 등록 export const postApplication = async (shopId: string, noticeId: string) => { - await axiosInstance.post(`/shops/${shopId}/notices/${noticeId}/applications`); + const res = await axiosInstance.post(`/shops/${shopId}/notices/${noticeId}/applications`); + return res.data; }; // 가게의 특정 공고 지원 취소 diff --git a/src/components/ui/badge/StatusBadge.tsx b/src/components/ui/badge/StatusBadge.tsx index 53bb9fd..4015d21 100644 --- a/src/components/ui/badge/StatusBadge.tsx +++ b/src/components/ui/badge/StatusBadge.tsx @@ -1,37 +1,12 @@ -import { Button } from '@/components/ui/button'; -import { UserRole } from '@/types/user'; import Badge from './Badge'; export type StatusType = 'pending' | 'accepted' | 'rejected'; interface StatusBadgeProps { status: StatusType; - userRole: UserRole; - onApprove: () => void; - onReject: () => void; - applicationId: string; - onStatusChange: (id: string, status: StatusType) => void; } -export default function StatusBadge({ - status, - userRole: variant, - onApprove, - onReject, -}: StatusBadgeProps) { - if (status === 'pending' && variant === 'employer') { - return ( -
- - -
- ); - } - +export default function StatusBadge({ status }: StatusBadgeProps) { const BADGE_CLASS = status === 'pending' ? 'bg-green-100 text-green-200' diff --git a/src/components/ui/badge/statusbadge.stories.tsx b/src/components/ui/badge/statusbadge.stories.tsx index 1e90ac0..21fbaa2 100644 --- a/src/components/ui/badge/statusbadge.stories.tsx +++ b/src/components/ui/badge/statusbadge.stories.tsx @@ -40,7 +40,7 @@ export const PendingEmployer: Story = { args: { status: 'pending', userRole: 'employer', - onApprove: () => alert('승인!'), + onStatusChange: () => alert('승인!'), onReject: () => alert('거절!'), }, }; diff --git a/src/components/ui/input/TimeInput.tsx b/src/components/ui/input/TimeInput.tsx index 3e35439..d0689d1 100644 --- a/src/components/ui/input/TimeInput.tsx +++ b/src/components/ui/input/TimeInput.tsx @@ -2,15 +2,15 @@ import TimeSelector from '@/components/ui/calendar/TimeSelector'; import useClickOutside from '@/hooks/useClickOutside'; import useToggle from '@/hooks/useToggle'; import { formatTime } from '@/lib/utils/dateFormatter'; -import { Period } from '@/types/calendar'; +import { Period, TimeValue } from '@/types/calendar'; import { useCallback, useEffect, useRef, useState } from 'react'; import Input from './input'; interface TimeInputProps { label?: string; requiredMark?: boolean; - value?: Date | null; - onChange?: (value: Date | null) => void; + value?: TimeValue | null; + onChange?: (value: TimeValue | null) => void; } export default function TimeInput({ @@ -20,23 +20,20 @@ export default function TimeInput({ onChange, }: TimeInputProps) { const { isOpen, toggle, setClose } = useToggle(false); - const [period, setPeriod] = useState('오전'); - const [selectedTime, setSelectedTime] = useState(null); - const [inputValue, setInputValue] = useState(''); // typing 사용 - + const [selectedTime, setSelectedTime] = useState(null); + const [inputValue, setInputValue] = useState(''); const wrapperRef = useRef(null); useClickOutside(wrapperRef, () => { if (isOpen) setClose(); }); - // 시간 업데이트 중앙 관리 const updateTime = useCallback( - (date: Date, selectedPeriod: Period) => { - setPeriod(selectedPeriod); - setSelectedTime(date); + (date: Date, period: Period) => { + const newTime: TimeValue = { date, period }; + setSelectedTime(newTime); setInputValue(formatTime(date)); - onChange?.(date); + onChange?.(newTime); }, [onChange] ); @@ -44,76 +41,80 @@ export default function TimeInput({ useEffect(() => { if (value) { setSelectedTime(value); - setInputValue(formatTime(value)); + setInputValue(formatTime(value.date)); } else { setSelectedTime(null); setInputValue(''); } }, [value]); - // 시간 선택 const handleTimeSelect = useCallback( - (value: string) => { - const parts = value.split(' '); - const periodValue = parts.length === 2 ? (parts[0] as Period) : period; - const timePart = parts.length === 2 ? parts[1] : parts[0]; - - const [hours, minutes] = timePart.split(':').map(Number); + (timeString: string) => { + const parts: string[] = timeString.split(' '); + const period: Period = parts.length === 2 ? (parts[0] as Period) : '오전'; + const [hoursStr, minutesStr] = parts[1].split(':'); + const hours = Number(hoursStr); + const minutes = Number(minutesStr); if (isNaN(hours) || isNaN(minutes)) return; - const baseDate = selectedTime ?? new Date(); - const newDate = new Date(baseDate); - newDate.setHours(hours, minutes); - - updateTime(newDate, periodValue); + const baseDate: Date = selectedTime?.date ?? new Date(); + const date = new Date(baseDate); + const hours24 = + period === '오후' && hours !== 12 + ? hours + 12 + : period === '오전' && hours === 12 + ? 0 + : hours; + date.setHours(hours24, minutes); + + updateTime(date, period); }, - [selectedTime, updateTime, period] + [selectedTime, updateTime] ); - // typing const handleTimeInputChange = (e: React.ChangeEvent) => { - const newTypedNumbers = e.target.value.replace(/[^0-9]/g, ''); - const typedLength = newTypedNumbers.length; + const digitsOnly: string = e.target.value.replace(/[^0-9]/g, ''); + setInputValue(digitsOnly); - setInputValue(newTypedNumbers); + if (digitsOnly.length < 3) return; - if (typedLength > 4) { - const hours = parseInt(newTypedNumbers.slice(0, typedLength - 2)); - - if (isNaN(hours) || hours < 1 || hours > 12) { - setInputValue(newTypedNumbers.slice(-1)); - return; - } - } + const hoursNum: number = parseInt(digitsOnly.slice(0, digitsOnly.length - 2), 10); + const minutesNum: number = parseInt(digitsOnly.slice(-2), 10); - if (typedLength < 3) return; + if (isNaN(hoursNum) || isNaN(minutesNum)) return; + if (!(hoursNum >= 1 && hoursNum <= 12 && minutesNum >= 0 && minutesNum < 60)) return; - const hoursTyped = newTypedNumbers.slice(0, typedLength - 2); - const minutesTyped = newTypedNumbers.slice(-2); + const period: Period = selectedTime?.period ?? (hoursNum >= 12 ? '오후' : '오전'); - const h = parseInt(hoursTyped); - const m = parseInt(minutesTyped); + const baseDate: Date = selectedTime?.date ?? new Date(); + const date = new Date(baseDate); + const hours24 = + period === '오후' && hoursNum !== 12 + ? hoursNum + 12 + : period === '오전' && hoursNum === 12 + ? 0 + : hoursNum; + date.setHours(hours24, minutesNum); - if (!isNaN(h) && !isNaN(m)) { - if (!(h >= 1 && h <= 12 && m >= 0 && m < 60)) return; - - const periodValue: Period = h > 12 ? '오후' : '오전'; - - const baseDate = selectedTime ?? new Date(); - const newDate = new Date(baseDate); - newDate.setHours(h, m); - - updateTime(newDate, periodValue); - } + updateTime(date, period); }; - const hours = selectedTime ? String(selectedTime.getHours() % 12 || 12).padStart(2, '0') : '12'; - const minutes = selectedTime ? String(selectedTime.getMinutes()).padStart(2, '0') : '00'; + const hoursDisplay: string = selectedTime + ? String(selectedTime.date.getHours() % 12 || 12).padStart(2, '0') + : '12'; + const minutesDisplay: string = selectedTime + ? String(selectedTime.date.getMinutes()).padStart(2, '0') + : '00'; + const periodDisplay: Period = selectedTime + ? selectedTime.date.getHours() >= 12 + ? '오후' + : '오전' + : '오전'; return (
diff --git a/src/components/ui/modal/notification/Notification.tsx b/src/components/ui/modal/notification/Notification.tsx index 42bded5..0930dfa 100644 --- a/src/components/ui/modal/notification/Notification.tsx +++ b/src/components/ui/modal/notification/Notification.tsx @@ -1,4 +1,5 @@ import Icon from '@/components/ui/icon/icon'; +import { cn } from '@/lib/utils/cn'; import { Notice } from '@/types/notice'; import { Shop } from '@/types/shop'; import { useState } from 'react'; @@ -21,7 +22,6 @@ interface NotificationProps { } 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; @@ -32,41 +32,51 @@ export default function Notification({ alerts, onRead, isOpen, onClose }: Notifi return ( <> - {/* 제어 모드가 아니면 내부 트리거 버튼을 노출 */} {!controlled && (
)} - {open && ( -
-
-
알림 {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..5657aed 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 ( -
-

공고 편집

- -
-
- setWage(e.currentTarget.value.replace(/\D+/g, ''))} - /> - - setWorkhour(Number(e.currentTarget.value))} - /> - - - setDate(selectedDate instanceof Date ? selectedDate : new Date(selectedDate)) - } - /> - - setTime(selectedTime)} - /> -
- -
- -