diff --git a/src/assets/icon/ic-result-badge.svg b/src/assets/icon/ic-result-badge.svg new file mode 100644 index 0000000..37226f1 --- /dev/null +++ b/src/assets/icon/ic-result-badge.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index d263c1a..bb80657 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -1,2 +1,4 @@ +export { Notification } from '@/components/ui/modal/notification'; +export { Table } from '@/components/ui/table'; export { Dropdown } from './dropdown'; export { Icon } from './icon'; diff --git a/src/components/ui/modal/index.ts b/src/components/ui/modal/index.ts index e69de29..a3db96e 100644 --- a/src/components/ui/modal/index.ts +++ b/src/components/ui/modal/index.ts @@ -0,0 +1 @@ +export { Notification } from '@/components/ui/modal/notification'; diff --git a/src/components/ui/modal/notification/Notification.stories.tsx b/src/components/ui/modal/notification/Notification.stories.tsx new file mode 100644 index 0000000..4df904d --- /dev/null +++ b/src/components/ui/modal/notification/Notification.stories.tsx @@ -0,0 +1,140 @@ +import { Meta, StoryFn } from '@storybook/nextjs'; +import Notification, { Alert } from './Notification'; + +/* eslint-disable no-console */ + +const meta: Meta = { + title: 'Components/Notification', + component: Notification, +}; + +export default meta; + +const Template: StoryFn = args => ; + +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, + }, + href: '/shop/shop1', + }, + notice: { + item: { + id: 'notice1', + hourlyPay: 15000, + description: '맛집 알바', + startsAt: '2025-10-01T09:00:00Z', + workhour: 8, + closed: false, + }, + 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, + }, + href: '/shop/shop2', + }, + notice: { + item: { + id: 'notice2', + hourlyPay: 12000, + description: '카페 알바', + startsAt: '2025-10-02T10:00:00Z', + workhour: 6, + closed: false, + }, + 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, + }, + href: '/shop/shop3', + }, + notice: { + item: { + id: 'notice3', + hourlyPay: 10000, + description: '도서관 알바', + startsAt: '2025-10-03T11:00:00Z', + workhour: 4, + closed: false, + }, + 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, + }, + href: '/shop/shop4', + }, + notice: { + item: { + id: 'notice4', + hourlyPay: 18000, + description: '헬스장 알바', + startsAt: '2025-10-04T09:00:00Z', + workhour: 5, + closed: false, + }, + href: '/notice/notice4', + }, + }, + ] as Alert[], + onRead: (id: string) => console.log('Read notification', id), +}; diff --git a/src/components/ui/modal/notification/Notification.tsx b/src/components/ui/modal/notification/Notification.tsx new file mode 100644 index 0000000..1bef66d --- /dev/null +++ b/src/components/ui/modal/notification/Notification.tsx @@ -0,0 +1,66 @@ +import Icon from '@/components/ui/icon/icon'; +import { Notice } from '@/types/notice'; +import { Shop } from '@/types/shop'; +import { useState } from 'react'; +import NotificationMessage from './NotificationMessage'; + +export interface Alert { + id: string; + createdAt: string; + result: 'accepted' | 'rejected'; + read: boolean; + shop: { item: Shop; href?: string }; + notice: { item: Notice; href?: string }; +} + +interface NotificationProps { + alerts: Alert[]; + onRead: (id: string) => void; + isOpen?: boolean; + onClose?: () => void; +} + +export default function Notification({ alerts, onRead }: NotificationProps) { + const [isOpen, setIsOpen] = useState(false); + 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 ( + <> +
+ +
+ {isOpen && ( +
+
+
알림 {notificationCount}개
+
+ +
+
+
+ {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 new file mode 100644 index 0000000..6c84d94 --- /dev/null +++ b/src/components/ui/modal/notification/NotificationMessage.tsx @@ -0,0 +1,61 @@ +import { getTime } from '@/lib/utils/getTime'; +import { timeAgo } from '@/lib/utils/timeAgo'; +import { clsx } from 'clsx'; +import { Alert } from './Notification'; +import ResultBadge from './ResultBadge'; + +export default function NotificationMessage({ + alert, + onRead, +}: { + alert: Alert; + onRead: (id: string) => void; +}) { + const { + id, + result, + read, + createdAt, + shop: { + item: { name: shopName }, + }, + notice: { + item: { startsAt, workhour }, + }, + } = alert; + + const RESULT_TEXT = result === 'accepted' ? '승인' : '거절'; + const DATE_RANGE = getTime(startsAt, workhour); + const NOTIFICATION_MESSAGE_CONTAINER = clsx( + 'w-full gap-2 break-words rounded border border-gray-200 bg-white px-3 py-4' + ); + + return ( +
+ +
+ ); +} diff --git a/src/components/ui/modal/notification/ResultBadge.tsx b/src/components/ui/modal/notification/ResultBadge.tsx new file mode 100644 index 0000000..9ba4d06 --- /dev/null +++ b/src/components/ui/modal/notification/ResultBadge.tsx @@ -0,0 +1,19 @@ +import Icon from '@/components/ui/icon/icon'; + +export interface ResultBadgeProps { + result: 'accepted' | 'rejected'; +} +const ICON_COLORS: Record = { + accepted: 'bg-blue-200', + rejected: 'bg-red-400', +}; + +export default function ResultBadge({ result }: ResultBadgeProps) { + return ( + + ); +} diff --git a/src/components/ui/modal/notification/index.ts b/src/components/ui/modal/notification/index.ts new file mode 100644 index 0000000..990ad9f --- /dev/null +++ b/src/components/ui/modal/notification/index.ts @@ -0,0 +1 @@ +export { default as Notification } from '@/components/ui/modal/notification/Notification'; diff --git a/src/components/ui/table/Table.stories.tsx b/src/components/ui/table/Table.stories.tsx index 06518d1..2569e45 100644 --- a/src/components/ui/table/Table.stories.tsx +++ b/src/components/ui/table/Table.stories.tsx @@ -1,8 +1,9 @@ import Table from '@/components/ui/table/Table'; import { TableRowProps } from '@/components/ui/table/TableRowProps'; +import { UserType } from '@/types/user'; import { Meta, StoryObj } from '@storybook/nextjs'; import { useEffect, useState } from 'react'; -import { fetchTableData, UserType } from './testApi'; +import { fetchTableData } from './testApi'; const meta: Meta = { title: 'UI/Table', @@ -39,5 +40,5 @@ export const TableExample: Story = { args: { userType: 'employer', }, - render: args => , + render: args => , }; diff --git a/src/components/ui/table/Table.tsx b/src/components/ui/table/Table.tsx index 5a67cb8..a9d99b8 100644 --- a/src/components/ui/table/Table.tsx +++ b/src/components/ui/table/Table.tsx @@ -1,10 +1,11 @@ import TableRow from '@/components/ui/table/TableRow'; import { TableRowProps } from '@/components/ui/table/TableRowProps'; +import { UserType } from '@/types/user'; interface TableProps { data: TableRowProps[]; headers: string[]; - userType: 'employer' | 'employee'; + userType: UserType; } // type은 확인이 좀 더 필요합니다 diff --git a/src/components/ui/table/TableRow.tsx b/src/components/ui/table/TableRow.tsx index dd29886..07af9c8 100644 --- a/src/components/ui/table/TableRow.tsx +++ b/src/components/ui/table/TableRow.tsx @@ -10,14 +10,14 @@ const TD_BASE = 'border-b px-2 py3'; const TD_STATUS = 'border-b px-2 py-[9px]'; export default function TableRow({ rowData, variant }: TableTypeVariant) { - const { STRAT, END, duration } = getTime(rowData.startsAt, rowData.workhour); + const { date, startTime, endTime, duration } = getTime(rowData.startsAt, rowData.workhour); return ( {variant === 'employee' ? ( <> - + ) : ( diff --git a/src/components/ui/table/testApi.tsx b/src/components/ui/table/testApi.tsx index 31756a6..1060a38 100644 --- a/src/components/ui/table/testApi.tsx +++ b/src/components/ui/table/testApi.tsx @@ -1,4 +1,4 @@ -export type UserType = 'employer' | 'employee'; +import type { UserType } from '@/types/user'; export const fetchTableData = async (userType: UserType) => { return new Promise<{ headers: string[]; data: unknown[] }>(resolve => { diff --git a/src/constants/icon.ts b/src/constants/icon.ts index a2528d4..58fed2d 100644 --- a/src/constants/icon.ts +++ b/src/constants/icon.ts @@ -17,6 +17,7 @@ import notificationOn from '@/assets/icon/ic-notification-on.svg'; import phone from '@/assets/icon/ic-phone.svg'; import radioOff from '@/assets/icon/ic-radio-off.svg'; import radioOn from '@/assets/icon/ic-radio-on.svg'; +import resultBadge from '@/assets/icon/ic-result-badge.svg'; import search from '@/assets/icon/ic-search.svg'; import successCircle from '@/assets/icon/ic-success-circle.svg'; import success from '@/assets/icon/ic-success.svg'; @@ -24,6 +25,7 @@ import warningCircle from '@/assets/icon/ic-warning-circle.svg'; import warning from '@/assets/icon/ic-warning.svg'; export const ICONS = { + resultBadge: resultBadge.src, arrowUp: arrowUp.src, calendar: calendar.src, camera: camera.src, diff --git a/src/lib/utils/getTime.ts b/src/lib/utils/getTime.ts index 9771439..b8abf35 100644 --- a/src/lib/utils/getTime.ts +++ b/src/lib/utils/getTime.ts @@ -2,17 +2,27 @@ function formatDate(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); + + return `${year}.${month}.${day}`; +} + +function formatTime(date: Date): string { const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}.${month}.${day} ${hours}:${minutes}`; + return `${hours}:${minutes}`; } export function getTime(startsAt: string, workhour: number) { - const START = new Date(startsAt); - const END = new Date(startsAt); + const startDate = new Date(startsAt); + const endDate = new Date(startsAt); - END.setHours(END.getHours() + workhour); + endDate.setHours(endDate.getHours() + workhour); - return { STRAT: formatDate(START), END: formatDate(END), duration: `${workhour}시간` }; + return { + date: formatDate(startDate), + startTime: formatTime(startDate), + endTime: formatTime(endDate), + duration: `${workhour}시간`, + }; } diff --git a/src/lib/utils/timeAgo.ts b/src/lib/utils/timeAgo.ts new file mode 100644 index 0000000..70bb9b7 --- /dev/null +++ b/src/lib/utils/timeAgo.ts @@ -0,0 +1,24 @@ +type timeUnit = { seconds: number; label: string }; + +const TIME_UNITS: timeUnit[] = [ + { seconds: 365 * 24 * 60 * 60, label: '년' }, + { seconds: 30 * 24 * 60 * 60, label: '개월' }, + { seconds: 24 * 60 * 60, label: '일' }, + { seconds: 60 * 60, label: '시간' }, + { seconds: 60, label: '분' }, +]; + +export function timeAgo(dateString: string): string { + const NOW = new Date(); + const DATE = new Date(dateString); + + const DIFF_SECONDS = Math.floor((NOW.getTime() - DATE.getTime()) / 1000); + + for (const UNIT of TIME_UNITS) { + const INTERVAL = Math.floor(DIFF_SECONDS / UNIT.seconds); + if (INTERVAL >= 1) { + return `${INTERVAL}${UNIT.label} 전`; + } + } + return '방금 전'; +} diff --git a/src/types/notice.ts b/src/types/notice.ts new file mode 100644 index 0000000..d2231c8 --- /dev/null +++ b/src/types/notice.ts @@ -0,0 +1,14 @@ +import { User } from './user'; + +export interface Notice { + id: string; + hourlyPay: number; + description: string; + startsAt: string; + workhour: number; + closed: boolean; + user?: { + item: User; + href?: string; + }; +}
{rowData.name}{`${STRAT} ~ ${END} (${duration})`}{`${date} ${startTime} ~ ${date} ${endTime}} (${duration})`} {rowData.hourlyPay}