diff --git a/src/api/applications.ts b/src/api/applications.ts index 96b3a35..2541112 100644 --- a/src/api/applications.ts +++ b/src/api/applications.ts @@ -1,6 +1,7 @@ import axiosInstance from '@/lib/axios'; import type { ApiResponse } from '@/types/api'; import { ApplicationItem, ApplicationListResponse } from '@/types/applications'; +import { NoticeCard } from '@/types/notice'; // 유저의 공고 지원 내역 전체 조회 export async function getAllUserApplications({ @@ -39,3 +40,25 @@ export const putApplication = async (shopId: string, noticeId: string, applicati status: 'canceled', }); }; + +// 특정 가게 특정 공고 정보 +export async function getNoticeById(shopId: string, noticeId: string): Promise { + const { data } = await axiosInstance.get(`/shops/${shopId}/notices/${noticeId}`); + return data; +} + +// 특정 가게 특정 공고 지원자 목록 +export async function getApplications( + shopId: string, + noticeId: string, + offset = 0, + limit = 5 +): Promise { + const { data } = await axiosInstance.get( + `/shops/${shopId}/notices/${noticeId}/applications`, + { params: { offset, limit } } + ); + const applications: ApplicationItem[] = data.items.map(resp => resp.item); + + return applications; +} diff --git a/src/components/ui/badge/StatusBadge.tsx b/src/components/ui/badge/StatusBadge.tsx index 54a66df..d3046df 100644 --- a/src/components/ui/badge/StatusBadge.tsx +++ b/src/components/ui/badge/StatusBadge.tsx @@ -1,7 +1,7 @@ import { Button } from '@/components/ui/button'; import Badge from './Badge'; -export type StatusType = 'pending' | 'approved' | 'rejected'; +export type StatusType = 'pending' | 'accepted' | 'rejected'; interface StatusBadgeProps { status: StatusType; @@ -27,11 +27,11 @@ export default function StatusBadge({ status, variant, onApprove, onReject }: St const BADGE_CLASS = status === 'pending' ? 'bg-green-100 text-green-200' - : status === 'approved' + : status === 'accepted' ? 'bg-blue-100 text-blue-200' : 'bg-red-100 text-red-400'; - const BADGE_TEXT = status === 'pending' ? '대기중' : status === 'approved' ? '승인 완료' : '거절'; + const BADGE_TEXT = status === 'pending' ? '대기중' : status === 'accepted' ? '승인 완료' : '거절'; return {BADGE_TEXT}; } diff --git a/src/components/ui/badge/statusbadge.stories.tsx b/src/components/ui/badge/statusbadge.stories.tsx index 5fa438b..218eeca 100644 --- a/src/components/ui/badge/statusbadge.stories.tsx +++ b/src/components/ui/badge/statusbadge.stories.tsx @@ -11,10 +11,10 @@ export default meta; type Story = StoryObj; -// Approve 승인 완료 뱃지 -export const Approve: Story = { +// Accept 승인 완료 뱃지 +export const Accept: Story = { args: { - status: 'approved', + status: 'accepted', variant: 'employer', }, }; @@ -35,7 +35,7 @@ export const PendingEmployee: Story = { }, }; -// Pending 대기중 employer 버튼 +// Pending 대기중 employer export const PendingEmployer: Story = { args: { status: 'pending', diff --git a/src/components/ui/card/notice/notice.tsx b/src/components/ui/card/notice/notice.tsx index 7e4a866..30dacbf 100644 --- a/src/components/ui/card/notice/notice.tsx +++ b/src/components/ui/card/notice/notice.tsx @@ -9,7 +9,7 @@ import { noticeWrapper } from './notice.styles'; interface NoticeProps> { notice: T; variant?: NoticeVariant; - children: ReactNode; + children?: ReactNode; className?: string; } diff --git a/src/components/ui/input/DateInput.tsx b/src/components/ui/input/DateInput.tsx index b4c8af6..31dda31 100644 --- a/src/components/ui/input/DateInput.tsx +++ b/src/components/ui/input/DateInput.tsx @@ -110,7 +110,7 @@ export default function DateInput({ }; return ( -
+
- {isOpen && ( -
- -
- )} +
+ +
); } diff --git a/src/components/ui/input/TimeInput.tsx b/src/components/ui/input/TimeInput.tsx index bac386a..3e35439 100644 --- a/src/components/ui/input/TimeInput.tsx +++ b/src/components/ui/input/TimeInput.tsx @@ -3,10 +3,22 @@ import useClickOutside from '@/hooks/useClickOutside'; import useToggle from '@/hooks/useToggle'; import { formatTime } from '@/lib/utils/dateFormatter'; import { Period } from '@/types/calendar'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import Input from './input'; -export default function TimeInput() { +interface TimeInputProps { + label?: string; + requiredMark?: boolean; + value?: Date | null; + onChange?: (value: Date | null) => void; +} + +export default function TimeInput({ + label = '시간 선택', + requiredMark = false, + value, + onChange, +}: TimeInputProps) { const { isOpen, toggle, setClose } = useToggle(false); const [period, setPeriod] = useState('오전'); const [selectedTime, setSelectedTime] = useState(null); @@ -19,11 +31,25 @@ export default function TimeInput() { }); // 시간 업데이트 중앙 관리 - const updateTime = useCallback((date: Date, selectedPeriod: Period) => { - setPeriod(selectedPeriod); - setSelectedTime(date); - setInputValue(formatTime(date)); - }, []); + const updateTime = useCallback( + (date: Date, selectedPeriod: Period) => { + setPeriod(selectedPeriod); + setSelectedTime(date); + setInputValue(formatTime(date)); + onChange?.(date); + }, + [onChange] + ); + + useEffect(() => { + if (value) { + setSelectedTime(value); + setInputValue(formatTime(value)); + } else { + setSelectedTime(null); + setInputValue(''); + } + }, [value]); // 시간 선택 const handleTimeSelect = useCallback( @@ -85,26 +111,27 @@ export default function TimeInput() { const minutes = selectedTime ? String(selectedTime.getMinutes()).padStart(2, '0') : '00'; return ( -
+
- {isOpen && ( -
- -
- )} +
+ +
); } diff --git a/src/components/ui/modal/notification/Notification.stories.tsx b/src/components/ui/modal/notification/Notification.stories.tsx index 4df904d..70d0ccc 100644 --- a/src/components/ui/modal/notification/Notification.stories.tsx +++ b/src/components/ui/modal/notification/Notification.stories.tsx @@ -1,140 +1,146 @@ -import { Meta, StoryFn } from '@storybook/nextjs'; +// Notification.stories.tsx +import type { Meta, StoryObj } from '@storybook/nextjs'; import Notification, { Alert } from './Notification'; -/* eslint-disable no-console */ - const meta: Meta = { title: 'Components/Notification', component: Notification, + parameters: { + layout: 'padded', + actions: { + handles: ['onRead'], + }, + }, + tags: ['autodocs'], }; export default meta; -const Template: StoryFn = args => ; +type Story = StoryObj; -export const Default = Template.bind({}); -Default.args = { - alerts: [ - { - id: '1', - read: false, - createdAt: '2025-10-03T14:14:00Z', - result: 'accepted', - shop: { - item: { - id: 'shop1', - name: '맛집 A', - category: '음식점', - address1: '서울 강남구', - address2: '역삼동 123-45', - description: '맛있는 음식점', - imageUrl: 'https://via.placeholder.com/150', - originalHourlyPay: 15000, +export const Default: Story = { + args: { + alerts: [ + { + id: '1', + read: false, + createdAt: '2025-10-03T14:14:00Z', + result: 'accepted', + shop: { + item: { + id: 'shop1', + name: '맛집 A', + category: '음식점', + address1: '서울 강남구', + address2: '역삼동 123-45', + description: '맛있는 음식점', + imageUrl: 'https://via.placeholder.com/150', + originalHourlyPay: 15000, + }, + href: '/shop/shop1', }, - href: '/shop/shop1', - }, - notice: { - item: { - id: 'notice1', - hourlyPay: 15000, - description: '맛집 알바', - startsAt: '2025-10-01T09:00:00Z', - workhour: 8, - closed: false, + notice: { + item: { + id: 'notice1', + hourlyPay: 15000, + description: '맛집 알바', + startsAt: '2025-10-01T09:00:00Z', + workhour: 8, + closed: false, + }, + href: '/notice/notice1', }, - href: '/notice/notice1', }, - }, - { - id: '2', - read: false, - createdAt: '2025-10-02T10:50:00Z', - result: 'rejected', - shop: { - item: { - id: 'shop2', - name: '카페 B', - category: '카페', - address1: '서울 서초구', - address2: '서초동 678-90', - description: '커피 맛집', - imageUrl: 'https://via.placeholder.com/150', - originalHourlyPay: 12000, + { + id: '2', + read: false, + createdAt: '2025-10-02T10:50:00Z', + result: 'rejected', + shop: { + item: { + id: 'shop2', + name: '카페 B', + category: '카페', + address1: '서울 서초구', + address2: '서초동 678-90', + description: '커피 맛집', + imageUrl: 'https://via.placeholder.com/150', + originalHourlyPay: 12000, + }, + href: '/shop/shop2', }, - href: '/shop/shop2', - }, - notice: { - item: { - id: 'notice2', - hourlyPay: 12000, - description: '카페 알바', - startsAt: '2025-10-02T10:00:00Z', - workhour: 6, - closed: false, + notice: { + item: { + id: 'notice2', + hourlyPay: 12000, + description: '카페 알바', + startsAt: '2025-10-02T10:00:00Z', + workhour: 6, + closed: false, + }, + href: '/notice/notice2', }, - href: '/notice/notice2', }, - }, - { - id: '3', - read: true, - createdAt: '2025-10-02T08:20:00Z', - result: 'accepted', - shop: { - item: { - id: 'shop3', - name: '도서관 C', - category: '도서관', - address1: '서울 마포구', - address2: '상암동 456-78', - description: '조용한 도서관', - imageUrl: 'https://via.placeholder.com/150', - originalHourlyPay: 10000, + { + id: '3', + read: true, + createdAt: '2025-10-02T08:20:00Z', + result: 'accepted', + shop: { + item: { + id: 'shop3', + name: '도서관 C', + category: '도서관', + address1: '서울 마포구', + address2: '상암동 456-78', + description: '조용한 도서관', + imageUrl: 'https://via.placeholder.com/150', + originalHourlyPay: 10000, + }, + href: '/shop/shop3', }, - href: '/shop/shop3', - }, - notice: { - item: { - id: 'notice3', - hourlyPay: 10000, - description: '도서관 알바', - startsAt: '2025-10-03T11:00:00Z', - workhour: 4, - closed: false, + notice: { + item: { + id: 'notice3', + hourlyPay: 10000, + description: '도서관 알바', + startsAt: '2025-10-03T11:00:00Z', + workhour: 4, + closed: false, + }, + href: '/notice/notice3', }, - href: '/notice/notice3', }, - }, - { - id: '4', - read: true, - createdAt: '2025-10-01T11:20:00Z', - result: 'rejected', - shop: { - item: { - id: 'shop4', - name: '헬스장 D', - category: '헬스장', - address1: '서울 송파구', - address2: '잠실동 789-01', - description: '피트니스 센터', - imageUrl: 'https://via.placeholder.com/150', - originalHourlyPay: 18000, + { + id: '4', + read: true, + createdAt: '2025-10-01T11:20:00Z', + result: 'rejected', + shop: { + item: { + id: 'shop4', + name: '헬스장 D', + category: '헬스장', + address1: '서울 송파구', + address2: '잠실동 789-01', + description: '피트니스 센터', + imageUrl: 'https://via.placeholder.com/150', + originalHourlyPay: 18000, + }, + href: '/shop/shop4', }, - href: '/shop/shop4', - }, - notice: { - item: { - id: 'notice4', - hourlyPay: 18000, - description: '헬스장 알바', - startsAt: '2025-10-04T09:00:00Z', - workhour: 5, - closed: false, + notice: { + item: { + id: 'notice4', + hourlyPay: 18000, + description: '헬스장 알바', + startsAt: '2025-10-04T09:00:00Z', + workhour: 5, + closed: false, + }, + href: '/notice/notice4', }, - href: '/notice/notice4', }, - }, - ] as Alert[], - onRead: (id: string) => console.log('Read notification', id), + ] as Alert[], + }, }; diff --git a/src/components/ui/table/TableRow.tsx b/src/components/ui/table/TableRow.tsx index 07beb3d..b65ea3d 100644 --- a/src/components/ui/table/TableRow.tsx +++ b/src/components/ui/table/TableRow.tsx @@ -17,7 +17,7 @@ export default function TableRow({ rowData, variant }: TableTypeVariant) { const { date, startTime, endTime, duration } = getTime(rowData.startsAt, rowData.workhour); const [status, setStatus] = useState(rowData.status as StatusType); - const handleApprove = () => setStatus('approved'); + const handleApprove = () => setStatus('accepted'); const handleReject = () => setStatus('rejected'); return ( diff --git a/src/components/ui/table/TableRowProps.tsx b/src/components/ui/table/TableRowProps.tsx index 8605805..5375c61 100644 --- a/src/components/ui/table/TableRowProps.tsx +++ b/src/components/ui/table/TableRowProps.tsx @@ -6,5 +6,5 @@ export type TableRowProps = { hourlyPay: string; status: string | JSX.Element; bio: string; - phone: number; + phone: string; }; diff --git a/src/pages/employer/notices/[id]/index.tsx b/src/pages/employer/notices/[id]/index.tsx deleted file mode 100644 index 52809ee..0000000 --- a/src/pages/employer/notices/[id]/index.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Button, Notice, Table } from '@/components/ui'; -import { TableRowProps } from '@/components/ui/table/TableRowProps'; -import { fetchTableData } from '@/components/ui/table/testApi'; -import { NoticeCard } from '@/types/notice'; -import { UserType } from '@/types/user'; -import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; -const oneDayMs = 24 * 60 * 60 * 1000; -export default function EmployerNoticeIdPage() { - const router = useRouter(); - const { query } = router; - const noticeId = Array.isArray(query.id) ? query.id[0] : query.id; - /* const { role } = useAuth(); useEffect(() => { if (role !== 'employer') { router.replace('/'); } }, [role, router]); */ const [ - notice, - setNotice, - ] = useState(); - const [headers, setHeaders] = useState([]); - const [data, setData] = useState([]); - const [offset, setOffset] = useState(0); - const limit = 5; - const count = data.length; - const paginatedData = data.slice(offset, offset + limit); - useEffect(() => { - if (!noticeId) return; - setNotice({ - id: noticeId, - hourlyPay: 20000, - startsAt: new Date(Date.now() + oneDayMs).toISOString(), - workhour: 4, - description: '주말 점심 시간대 근무자를 모집합니다.', - closed: false, - shopId: 'shop-bridge', - name: '한강 브런치 카페', - category: '카페', - address1: '서울시 용산구', - shopDescription: '한강 뷰를 자랑하는 브런치 카페', - imageUrl: 'https://picsum.photos/id/1080/640/360', - originalHourlyPay: 18000, - }); - }, [noticeId]); - useEffect(() => { - const loadTable = async () => { - const result = await fetchTableData('employer' as UserType); - setHeaders(result.headers); - setData(result.data as TableRowProps[]); - }; - loadTable(); - }, []); - if (!notice) return; - return ( - <> -
- - - -
-
- - - - ); -} diff --git a/src/pages/employer/shops/[shopId]/notices/[noticeId]/index.tsx b/src/pages/employer/shops/[shopId]/notices/[noticeId]/index.tsx new file mode 100644 index 0000000..75743a2 --- /dev/null +++ b/src/pages/employer/shops/[shopId]/notices/[noticeId]/index.tsx @@ -0,0 +1,97 @@ +import { getApplications, getNoticeById } from '@/api/applications'; +import { Button, Notice, Table } from '@/components/ui'; +import { TableRowProps } from '@/components/ui/table/TableRowProps'; +import useAuth from '@/hooks/useAuth'; +import { NoticeCard } from '@/types/notice'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; + +const EmployerNoticePage = () => { + const router = useRouter(); + const { query } = router; + const shopId = Array.isArray(query.shopId) ? query.shopId[0] : query.shopId; + const noticeId = Array.isArray(query.noticeId) ? query.noticeId[0] : query.noticeId; + + const { user } = useAuth(); + + const [notice, setNotice] = useState(); + + const [headers, setHeaders] = useState([]); + const [data, setData] = useState([]); + + const [offset, setOffset] = useState(0); + const limit = 5; + + // employer만 접근 가능 + useEffect(() => { + if (user && !user.shop) { + router.replace('/'); + } + }, [user, router]); + + // 공고 조회 + useEffect(() => { + if (!shopId || !noticeId) return; + + const fetchNotice = async () => { + const result = await getNoticeById(shopId, noticeId); + setNotice(result); + }; + + fetchNotice().catch(() => { + setNotice(undefined); + }); + }, [shopId, noticeId]); + + // 지원자 목록 조회 + useEffect(() => { + if (!shopId || !noticeId) return; + + const fetchApplications = async () => { + const applications = await getApplications(shopId, noticeId, offset, limit); + setHeaders(['신청자', '소개', '전화번호', '상태']); + setData( + applications.map(app => ({ + id: app.id, + name: app.user?.name ?? '-', + startsAt: '-', + workhour: 0, + hourlyPay: '-', + status: app.status, + bio: '-', + phone: '-', + })) + ); + }; + + fetchApplications(); + }, [shopId, noticeId, offset]); + + if (!notice) return null; + + return ( +
+ + + +
+ + ); +}; + +export default EmployerNoticePage; diff --git a/src/pages/employer/shops/[shopId]/notices/register/index.tsx b/src/pages/employer/shops/[shopId]/notices/register/index.tsx new file mode 100644 index 0000000..3571d44 --- /dev/null +++ b/src/pages/employer/shops/[shopId]/notices/register/index.tsx @@ -0,0 +1,156 @@ +import { Button, DateInput, Input, Modal, TimeInput } from '@/components/ui'; +import useAuth from '@/hooks/useAuth'; +import axiosInstance from '@/lib/axios'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; + +interface NoticePayload { + hourlyPay: number; + startsAt: string; + workhour: number; + description: string; +} + +const EmployerNoticeRegisterPage = () => { + const router = useRouter(); + const { user } = useAuth(); + + const [wage, setWage] = useState(''); + const [date, setDate] = useState(null); + const [time, setTime] = useState(null); + const [workhour, setWorkhour] = useState(); + const [description, setDescription] = useState(''); + + const [modalOpen, setModalOpen] = useState(false); + + useEffect(() => { + if (user && !user.shop) { + alert('접근 권한이 없습니다.'); + router.replace('/'); + } + }, [user, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!date || !time || !wage || !workhour || !description) return; + + const combinedDateTime = new Date(date); + combinedDateTime.setHours(time.getHours(), time.getMinutes(), 0, 0); + + const payload: NoticePayload = { + hourlyPay: Number(wage), + startsAt: combinedDateTime.toISOString(), + workhour, + description, + }; + + if (!user?.shop) return; + + try { + await axiosInstance.post(`/shops/${user.shop.item.id}/notices`, payload); + setModalOpen(true); + } catch (error) { + alert(error instanceof Error ? error.message : '등록 중 오류 발생'); + } + }; + + const handleModalClose = () => { + setModalOpen(false); + router.push(`/my-shop`); + }; + + if (!user?.shop) return null; + + return ( +
+

공고 등록

+ +
+
+ ) => + setWage(e.currentTarget.value.replace(/\D+/g, '')) + } + /> + + ) => + setWorkhour(Number(e.currentTarget.value)) + } + /> + + { + if (selectedDate instanceof Date) { + setDate(selectedDate); + } else { + setDate(new Date(selectedDate)); + } + }} + /> + + setTime(selectedTime)} + /> +
+ +
+ +