diff --git a/README.md b/README.md index db47f17..20283f9 100644 --- a/README.md +++ b/README.md @@ -2,39 +2,29 @@ 노션 협업 문서를 바탕으로 정리한 **중급 프로젝트 README**입니다. ---- - -## 📌 개요 - - **프로젝트 기간**: 2025-09-29 ~ 2025-10-22 (제출 마감 23:50) - **목표** - 알바 공고와 가게/지원자를 잇는 서비스의 핵심 플로우(**회원가입 → 프로필 → 공고 등록·조회 → 상세**) 완성 - 공통 UI 컴포넌트(버튼/인풋/모달 등) 정리 및 **Storybook 문서화** - **배포 파이프라인(Vercel)**과 팀 협업 파이프라인 정착 -- **핵심지표(예시)**: 기능 커버리지, QA 체크리스트 통과율, Storybook 커버리지, E2E 통과 --- ## 👥 팀 & 역할 (RnR) -| 구성원 | 공통 작업 | UI 컴포넌트 | 페이지 | - -|---|---|---|---| - -| 팀원전원 | 디자인 및 기능 QA / 본인 작업관련 문서 | | | +| | | | | +| :------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------: | +| 위소현(팀장)
[@sohyun0](https://github.com/sohyun0) | 박신천
[@jeschun](https://github.com/jeschun) | 양재영
[@BaeZzi813](https://github.com/BaeZzi813) | 유인화
[@gummmmmy0v0](https://github.com/gummmmmy0v0) | -| 박신천 | | 인풋, 모달, 버튼 | 로그인, 회원가입, 내프로필 등록, 상세 | +
-| 양재영 | 시연영상 준비 | 공통 프레임, 푸터, 토스트, 페이지네이션, 스켈레톤 UI | 가게 정보 등록, -상세 | - -| 위소현 | 초기 프로젝트 셋팅 / 공용 문서 작성 및 관리(노션) | 헤더, 필터, 드롭다운, post, 컬러, -폰트, 아이콘 | 공고 리스트, 상세 | - -| 유인화 | 발표자료 및 발표 | 테이블, 알림, 캘린더 | 가게 공고 등록, 상세 | - -> 담당 범위는 개발 중 상호 협의로 조정될 수 있음. +| 구성원 | 역할 | 공통 작업 | UI 컴포넌트 | 페이지 | +| :---------------: | :----------------: | :----------------------------------------------------: | :-------------------------------------------------------------------: | :-------------------------------------------: | +| **위소현 (팀장)** | 프로젝트 관리/발표 | 초기 프로젝트 셋팅, 공용 문서 작성 및 관리(노션) | 헤더, 필터, 드롭다운, Post, 컨테이너, 스켈레톤 UI, 컬러, 폰트, 아이콘 | 공고 리스트, 공고 상세 | +| **박신천** | FE | 디자인 및 기능 QA, 본인 작업 관련 문서 | 인풋, 모달, 버튼 | 로그인, 회원가입, 내 프로필 등록, 프로필 상세 | +| **양재영** | FE | 시연 영상 준비, 디자인 및 기능 QA, 본인 작업 관련 문서 | 공통 프레임, 푸터, 토스트, 페이지네이션 | 가게 정보 등록, 가게 상세 | +| **유인화** | FE | 발표 자료 준비, 디자인 및 기능 QA, 본인 작업 관련 문서 | 테이블, 알림, 캘린더 | 가게 공고 등록, 공고 상세 | --- @@ -48,8 +38,6 @@ - **GitHub Issues/Projects** · 브랜치 전략 · 템플릿 - **Discord/GitHub 웹훅** -> ※ “기술 스택 선정 배경” 문서는 베이스로 존재하며 상세 설명은 후속 업데이트 예정. - --- ## ✨ 주요 기능 @@ -63,72 +51,16 @@ --- -## 🗓️ 진행 일정 (요약) - -- **전체 기간**: 2025-09-29 ~ 2025-10-22 -- 1차 중간점검 → 2차 중간점검 → 테스트·수정·코드개선 → 발표 준비 → 최종 점검 및 제출 - -### ▸ 프로젝트 일정표 - -- **1. 프로젝트 주제 선정** — 2025/09/25 → 2025/09/26 - - 주제 선정, 기획 분석 및 RnR 분배 -- **2. 프로젝트 초기 셋팅 및 필요 지식 습득** — 2025/09/27 → 2025/09/28 - - Github repo 만들기, Github fork, project todo 작성 등 / 초기 셋팅 재점검(config 등) / Tailwind - 컬러·폰트 정의 후 배포 -- **3. 공통 UI 컴포넌트 제작** — 2025/09/28 → 2025/10/01 - - 버튼, 인풋, 모달 등 최소 UI → 이후 페이지 작업 가능 상태 만들기 -- **4. 1차 중간점검** — 2025/10/01 - - 2시 팀 미팅에서 공통 리소스 점검, 마감 19:00 -- **5. 기능 컴포넌트 및 페이지 제작** — 2025/10/04 → 2025/10/15 - - 퍼블리싱·API 등 스타일에 맞춰 구현 / 마감은 매일 14:00 팀 미팅 전 -- **6. 2차 중간점검** — 2025/10/16 - - 기능 및 디자인 QA, 요구사항 체크리스트 점검 -- **7. 테스트 및 수정, 코드 개선** — 2025/10/16 → 2025/10/19 - - QA에서 나온 버그/수정사항 반영 및 개선 -- **8. 프로젝트 발표 준비** — 2025/10/18 → 2025/10/19 - - PPT/영상 준비(개인 스케줄에 맞춤) → 발표 시 영상 또는 시연 -- **9. 최종점검** — 2025/10/20 - - 모든 버그 및 요구사항 체크리스트 통과 -- **10. 발표** — 2025/10/21 - - 13:00 발표 → 이후 PPT/영상 산출물 팀 공유(제출용) -- **11. 완성 및 제출** — 2025/10/22 - - 코드 작업 마감 19:00 / 결과물 제출 23:50 - -### ▸ 세부 계획 - -- 상기 일정과 동일한 마일스톤 기준으로 각 작업을 세분화하여 진행 (퍼블리싱, API 연동, 리팩토링, - 테스트/QA, 산출물 제작 등) - ---- +## 🚀 워크플로우 개요 -## ✅ 초기 셋업 TODO 현황 - -- [O] 프로젝트 협업 문서 작성 및 업데이트 (커뮤니케이션, 컨벤션, 프로젝트 관리, 기술스택) -- [O] 기술스택 설치 + package script 작성 -- [O] ESLint 설정 -- [O] Prettier 설정 -- [O] Tailwind config 공통 설정 (컬러, 폰트) -- [O] Storybook 공통 작성 (컬러, 폰트, 아이콘) -- [O] Vercel 연동 -- [O] Discord · GitHub 웹훅 연동 -- [O] GitHub repo/브랜치/이슈 템플릿/프로젝트 추가 -- [O] 웹폰트 작성 (CDN 미제공) -- [O] 기타 기본 설정: `.gitignore` / `.editorconfig` / `.storybook` / `.vscode` -- [O] `next.config` 이미지 경로 설정 -- [O] `.env.example` 작성 -- [O] 기본 폴더 구조 생성(+ `index.ts` 포함) -- [O] 공통 아이콘 이미지 export -- [O] Tailwind util 함수 작성 +![워크플로우 다이어그램](src/assets/images/workflow.png) --- -## 🤝 협업 컨벤션 +### ▸ 세부 계획 -- **브랜치**: `main`(보호) / `develop` / `feature/*` (이슈 번호 기반 네이밍) -- **커밋**: Gitmoji/Conventional 권장, 작은 단위로 빈번하게 -- **PR**: 템플릿 사용 · 체크리스트 · 스크린샷/Storybook 링크 첨부 -- **이슈/프로젝트**: 작업 단위 이슈 → PR 연결, 라벨/담당자/마일스톤 운영 -- **QA**: 데일리 스크럼 기준으로 진행, 디자인/기능 QA는 전원 공통 참여 +- 상기 일정과 동일한 마일스톤 기준으로 각 작업을 세분화하여 진행 (퍼블리싱, API 연동, 리팩토링, + 테스트/QA, 산출물 제작 등) --- @@ -138,105 +70,43 @@ > 취급합니다. ``` -project-root/ -├── 📁 components/ # 재사용 가능한 UI 컴포넌트 -│ ├── 📁 common/ # 공통 컴포넌트 (SEO, ErrorBoundary 등) -│ ├── 📁 ui/ # 기본 UI 컴포넌트 (Button, Input 등) -│ ├── 📁 layout/ # 레이아웃 컴포넌트 (Header, footer) -│ └── 📁 features/ # 기능 컴포넌트 (Form, Post, List) -│ -├── 📁 pages/ # Next.js Pages Router -│ ├── 📁 dashboard/ # 구현할 페이지 예시 (대시보드 페이지들) -│ ├── 📁 mypage/ # 구현할 페이지 예시 (마이페이지) -│ ├── _app.ts # 글로벌 App 컴포넌트 -│ ├── _document.ts # HTML Document 커스터마이징 -│ └── index.ts # 메인페이지 -│ -├── 📁 api/ # API 관련코드 (백엔드 통신 전용, next.js는 단순 프론트로 사용) -│ ├── 📁 posts/ -│ └── 📁 users/ -│ -├── 📁 lib/ # 유틸리티 및 설정 -│ ├── 📁 axios/ # API 요청 헬퍼 axios instance -│ ├── 📁 utils/ # 유틸리티 함수들 (날짜 포맷터 등) -│ └── 📁 validators/ # 유효성 검사 -│ -├── 📁 assets/ # 정적 파일 -│ ├── 📁 icons/ # 아이콘 (SVG, PNG 등) -│ ├── 📁 images/ # 배너, 일러스트, 배경 이미지 -│ └── 📁 fonts/ # 웹폰트 (필요 시) -│ -├── 📁 hooks/ # 공통 커스텀 React 훅 -├── 📁 context/ # 전역 상태 (React Context API) -├── 📁 styles/ # 스타일 파일들 -> 테일윈드 전역 파일 -├── 📁 constants/ # 전역 상수 관리 -├── 📁 types/ # TypeScript 타입 정의 -│ -├── .env.local # 환경 변수 -├── tsconfig.json -├── package.json -└── next.config.ts +src +├── api +├── assets +│ ├── font +│ ├── icon +│ └── images +├── components +│ ├── features +│ ├── layout +│ └── ui +├── constants +├── context +├── hooks +├── lib +├── pages +│ ├── employer +│ ├── my-profile +│ ├── my-shop +│ ├── notices +│ ├── search.tsx +│ ├── login.tsx +│ ├── signup.tsx +│ ├── index.tsx +│ └── 404.tsx +├── stories +├── styles +└── types ``` > 구현 팁 > > - `components/features/`는 **도메인 단위 UI**(예: `PostForm`, `PostList`) 중심. > - `pages/`에서는 **라우팅과 데이터 주입**만 담당하도록 분리. -> - `lib/axios/`에 **axios instance**·인터셉터·에러 핸들러 배치. -> - `lib/validators/`는 Zod/Yup 중 택1, 폼과 API 경계에서 활용. > - `constants/`에 라우트/키/에러 메시지 상수화 → 하드코딩 방지. --- -## 🚀 빠른 시작 - -```bash -# 1) 설치 -pnpm install # 또는 yarn/npm - -# 2) 개발 서버 -pnpm dev # http://localhost:3000 - -# 3) 린트/포맷/빌드 -pnpm lint -pnpm format -pnpm build - -# 4) 스토리북 -pnpm storybook -``` - -### 환경변수 (.env.example) - -``` -NEXT_PUBLIC_API_BASE=... -``` - -> `next.config.ts`의 이미지 설정과 함께 사용합니다. - ---- - -## 🧭 라우팅(워크플로우) 메모 - -- `/` : 메인 -- `/list` : 룰패 목록 -- `/post` : 룰패(템플릿) 생성 -- `/post/{id}` : 룰패 메시지 확인 -- `/post/{id}/message` : 룰패 메시지 작성 -- `/post/{id}/edit` : 룰패 메시지 수정 -- (공통 UI) Modal/Toast/Dropdown 등은 `components/ui/`에서 제공 - -> 리포지토리에 `workflow.png`를 README와 같은 폴더에 두면 `![Workflow](./workflow.png)`로 바로 -> 미리보기 가능합니다. - ---- - ## 📝 문서 출처 -- 노션: _중급 프로젝트 협업 문서_ (프로젝트 수행 계획서, RnR, 데일리 스크럼, 기술 스택 배경, - 프로젝트/세부 일정 CSV 등) - -### 메모 - -- 초기 셋업 TODO는 완료됨: ESLint/Prettier, Tailwind config(컬러/폰트), Storybook 공통, Vercel 배포 - 연동, GitHub/Discord 웹훅, 기본 폴더/아이콘 export, `.env.example`, util 함수 등. +- 노션: [여기서 확인](https://www.notion.so/26f46d4d7ef780dab24cf1d09dcb611e) diff --git a/src/api/alerts.ts b/src/api/alerts.ts index d6b34fb..2e39ba5 100644 --- a/src/api/alerts.ts +++ b/src/api/alerts.ts @@ -1,4 +1,3 @@ -// src/api/alerts.ts import axios from '@/lib/axios'; import type { ApiResponse, PaginatedResponse } from '@/types/api'; import type { Notice } from '@/types/notice'; 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/assets/images/workflow.png b/src/assets/images/workflow.png new file mode 100644 index 0000000..47cee66 Binary files /dev/null and b/src/assets/images/workflow.png differ diff --git a/src/components/features/noticeList/customNotice.tsx b/src/components/features/noticeList/customNotice.tsx index bc5f093..a5ca026 100644 --- a/src/components/features/noticeList/customNotice.tsx +++ b/src/components/features/noticeList/customNotice.tsx @@ -20,11 +20,14 @@ const CustomNoticeList = () => { /> ) : ( - {notices.map(notice => ( -
  • - -
  • - ))} + {notices.map(notice => { + const href = `/notices/${notice.shopId}/${notice.id}`; + return ( +
  • + +
  • + ); + })}
    )} diff --git a/src/components/features/noticeList/noticeList.tsx b/src/components/features/noticeList/noticeList.tsx index 5294dc2..0cce027 100644 --- a/src/components/features/noticeList/noticeList.tsx +++ b/src/components/features/noticeList/noticeList.tsx @@ -24,9 +24,10 @@ const NoticeList = ({ notices, q, isLoading, isInitialized, reset, error }: Noti return (
    - {notices.map(notice => ( - - ))} + {notices.map(notice => { + const href = `/notices/${notice.shopId}/${notice.id}`; + return ; + })}
    ); }; diff --git a/src/components/features/noticeList/recentNoticeList.tsx b/src/components/features/noticeList/recentNoticeList.tsx index fbf9ecd..1fe8c7c 100644 --- a/src/components/features/noticeList/recentNoticeList.tsx +++ b/src/components/features/noticeList/recentNoticeList.tsx @@ -11,9 +11,10 @@ const RecentNoticeList = () => {

    최근에 본 공고

    - {recentNotices.map(notice => ( - - ))} + {recentNotices.map(notice => { + const href = `/notices/${notice.shopId}/${notice.id}`; + return ; + })}
    ); diff --git a/src/components/layout/frame/frame.tsx b/src/components/layout/frame/frame.tsx index 5c7d5f1..65670e2 100644 --- a/src/components/layout/frame/frame.tsx +++ b/src/components/layout/frame/frame.tsx @@ -13,7 +13,7 @@ const Frame = ({ title, content, buttonText, href }: FrameProps) => { return ( <> -

    {title}

    +

    {title}

    {content}

    diff --git a/src/components/layout/header/nav.tsx b/src/components/layout/header/nav.tsx index 49f0ea2..d67102d 100644 --- a/src/components/layout/header/nav.tsx +++ b/src/components/layout/header/nav.tsx @@ -24,6 +24,7 @@ const NAV_ITEMS: Record = { const Nav = () => { const { role, isLogin, logout } = useAuth(); const { applications } = useUserApplications(); + const [open, setOpen] = useState(false); // 읽음 처리한 알림 ID들 (간단 로컬 상태) const [readIds, setReadIds] = useState>(new Set()); @@ -43,10 +44,6 @@ const Nav = () => { })); }, [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); @@ -55,39 +52,67 @@ const Nav = () => { }); }; + // role이 초기 undefined일 수 있어 방어 + const currentRole: UserRole = (role ?? 'guest') as UserRole; + + // 아이콘은 "패널 열림 상태"로만 토글 + const bellIcon: 'notificationOn' | 'notificationOff' = open + ? 'notificationOn' + : 'notificationOff'; + return ( ); }; + export default Nav; 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..f78403e 100644 --- a/src/components/ui/badge/statusbadge.stories.tsx +++ b/src/components/ui/badge/statusbadge.stories.tsx @@ -15,7 +15,6 @@ type Story = StoryObj; export const Accept: Story = { args: { status: 'accepted', - userRole: 'employer', }, }; @@ -23,7 +22,6 @@ export const Accept: Story = { export const Reject: Story = { args: { status: 'rejected', - userRole: 'employer', }, }; @@ -31,7 +29,6 @@ export const Reject: Story = { export const PendingEmployee: Story = { args: { status: 'pending', - userRole: 'employee', }, }; @@ -39,8 +36,5 @@ export const PendingEmployee: Story = { export const PendingEmployer: Story = { args: { status: 'pending', - userRole: 'employer', - onApprove: () => alert('승인!'), - onReject: () => alert('거절!'), }, }; diff --git a/src/components/ui/card/notice/mockData/mockData.json b/src/components/ui/card/notice/mockData/mockData.json deleted file mode 100644 index 3803363..0000000 --- a/src/components/ui/card/notice/mockData/mockData.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "item": { - "id": "notice-001", - "hourlyPay": 20000, - "startsAt": "2025-10-11T11:00:00Z", - "workhour": 4, - "description": "주말 점심 시간대 근무자를 모집합니다.", - "closed": false, - "shop": { - "item": { - "id": "shop-bridge", - "name": "여의도 베이커리 카페", - "category": "카페", - "address1": "서울시 영등포구", - "address2": "여의도동 2가 123-45", - "description": "여의도 한강 뷰를 즐길 수 있는 베이커리 카페! 직장인이 많은 곳이라 평일 점심에만 많이바쁘고 그 외는 한가한 편입니다.", - "imageUrl": "https://picsum.photos/id/16/640/360", - "originalHourlyPay": 18000 - }, - "href": "/shops/shop-bridge" - }, - "currentUserApplication": null - }, - "links": [ - { - "rel": "self", - "description": "공고 정보", - "method": "GET", - "href": "/shops/shop-bridge/notices/notice-001" - }, - { - "rel": "update", - "description": "공고 수정", - "method": "PUT", - "href": "/shops/shop-bridge/notices/notice-001", - "body": { - "hourlyPay": "number", - "startsAt": "string", - "workhour": "string", - "description": "string" - } - }, - { - "rel": "applications", - "description": "지원 목록", - "method": "GET", - "href": "/shops/shop-bridge/notices/notice-001/applications", - "query": { - "offset": "undefined | number", - "limit": "undefined | number" - } - }, - { - "rel": "create", - "description": "지원하기", - "method": "POST", - "href": "/shops/shop-bridge/notices/notice-001/applications" - }, - { - "rel": "shop", - "description": "가게 정보", - "method": "GET", - "href": "/shops/shop-bridge" - }, - { - "rel": "list", - "description": "공고 목록", - "method": "GET", - "href": "/shops/shop-bridge/notices", - "query": { - "offset": "undefined | number", - "limit": "undefined | number" - } - } - ] -} diff --git a/src/components/ui/card/notice/mockData/noticeWrapper.tsx b/src/components/ui/card/notice/mockData/noticeWrapper.tsx deleted file mode 100644 index e1cb3b8..0000000 --- a/src/components/ui/card/notice/mockData/noticeWrapper.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Button } from '@/components/ui/button'; -import Notice from '@/components/ui/card/notice/notice'; -import { getNoticeStatus } from '@/lib/utils/getNoticeStatus'; -import { type NoticeCard } from '@/types/notice'; -import { NoticeShopCard } from '@/types/shop'; -import Link from 'next/link'; -import mockResponse from './mockData.json'; -import shopMockResponse from './shopMockData.json'; - -// mockData -type RawNotice = typeof mockResponse; -type RawShopNotice = typeof shopMockResponse; - -const toNoticeCard = ({ item }: RawNotice): NoticeCard => { - const shop = item.shop.item; - - return { - id: item.id, - hourlyPay: item.hourlyPay, - startsAt: item.startsAt, - workhour: item.workhour, - description: item.description, - closed: item.closed, - shopId: shop.id, - name: shop.name, - category: shop.category, - address1: shop.address1, - shopDescription: shop.description, - imageUrl: shop.imageUrl, - originalHourlyPay: shop.originalHourlyPay, - }; -}; -const toShopCard = ({ item }: RawShopNotice): NoticeShopCard => { - const shop = item; - - return { - shopId: shop.id, - name: shop.name, - category: shop.category, - address1: shop.address1, - shopDescription: shop.description, - imageUrl: shop.imageUrl, - }; -}; - -const NoticeWrapper = () => { - // notice - const notice: NoticeCard = toNoticeCard(mockResponse); - const status = getNoticeStatus(notice.closed, notice.startsAt); - const href = `/shops/${notice.shopId}/notices/${notice.id}`; - // shop - const shopItem: NoticeShopCard = toShopCard(shopMockResponse); - return ( - <> - - - - - - - - ); -}; - -export default NoticeWrapper; diff --git a/src/components/ui/card/notice/mockData/shopMockData.json b/src/components/ui/card/notice/mockData/shopMockData.json deleted file mode 100644 index 95e2909..0000000 --- a/src/components/ui/card/notice/mockData/shopMockData.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "item": { - "id": "4490151c-5217-4157-b072-9c37b05bed47", - "name": "진주회관", - "category": "한식", - "address1": "서울시 중구", - "address2": "세종대로11길 26", - "description": "콩국수 맛집 인정따리", - "imageUrl": "https://bootcamp-project-api.s3.ap-northeast-2.amazonaws.com/0-1/the-julge/1bdb43c8-ff08-4a46-81b0-7f91efced98c-jinju4.png", - "originalHourlyPay": 10000, - "user": { - "item": { - "id": "4e560aa8-ae5a-40e1-a6e0-2a1e8b866d17", - "email": "test-employer1@codeit.com", - "type": "employer" - }, - "href": "/users/4e560aa8-ae5a-40e1-a6e0-2a1e8b866d17" - } - }, - "links": [ - { - "rel": "self", - "description": "가게 정보", - "method": "GET", - "href": "/shops/4490151c-5217-4157-b072-9c37b05bed47" - }, - { - "rel": "update", - "description": "가게 정보 수정", - "method": "PUT", - "href": "/shops/4490151c-5217-4157-b072-9c37b05bed47", - "body": { - "name": "string", - "category": "한식 | 중식 | 일식 | 양식 | 분식 | 카페 | 편의점 | 기타", - "address1": "서울시 종로구 | 서울시 중구 | 서울시 용산구 | 서울시 성동구 | 서울시 광진구 | 서울시 동대문구 | 서울시 중랑구 | 서울시 성북구 | 서울시 강북구 | 서울시 도봉구 | 서울시 노원구 | 서울시 은평구 | 서울시 서대문구 | 서울시 마포구 | 서울시 양천구 | 서울시 강서구 | 서울시 구로구 | 서울시 금천구 | 서울시 영등포구 | 서울시 동작구 | 서울시 관악구 | 서울시 서초구 | 서울시 강남구 | 서울시 송파구 | 서울시 강동구", - "address2": "string", - "description": "string", - "imageUrl": "string", - "originalHourlyPay": "number" - } - }, - { - "rel": "user", - "description": "가게 주인 정보", - "method": "GET", - "href": "/users/4e560aa8-ae5a-40e1-a6e0-2a1e8b866d17" - }, - { - "rel": "notices", - "description": "공고 목록", - "method": "GET", - "href": "/shops/4490151c-5217-4157-b072-9c37b05bed47/notices", - "query": { - "offset": "undefined | number", - "limit": "undefined | number" - } - } - ] -} diff --git a/src/components/ui/card/post/mockData/mockData.json b/src/components/ui/card/post/mockData/mockData.json deleted file mode 100644 index 019c0f9..0000000 --- a/src/components/ui/card/post/mockData/mockData.json +++ /dev/null @@ -1,231 +0,0 @@ -{ - "offset": 0, - "limit": 3, - "address": [], - "count": 6, - "hasNext": false, - "items": [ - { - "item": { - "id": "notice-001", - "hourlyPay": 18000, - "startsAt": "2025-10-11T11:00:00Z", - "workhour": 4, - "description": "주말 점심 시간대 근무자를 모집합니다.", - "closed": false, - "shop": { - "item": { - "id": "shop-bridge", - "name": "한강 브런치 카페", - "category": "카페", - "address1": "서울시 용산구", - "address2": "한강로 2가 123-45", - "description": "한강 뷰를 자랑하는 브런치 카페", - "imageUrl": "https://picsum.photos/id/1080/640/360", - "originalHourlyPay": 18000 - }, - "href": "/shops/shop-bridge" - } - }, - "links": [ - { - "rel": "self", - "description": "공고 상세", - "method": "GET", - "href": "/notices/notice-001" - }, - { - "rel": "shop", - "description": "가게 상세", - "method": "GET", - "href": "/shops/shop-bridge" - } - ] - }, - { - "item": { - "id": "notice-002", - "hourlyPay": 20000, - "startsAt": "2026-08-15T18:30:00Z", - "workhour": 5, - "description": "저녁 피크 타임 대응 인력을 찾습니다.", - "closed": false, - "shop": { - "item": { - "id": "shop-chicken", - "name": "홍대 치킨 공방", - "category": "음식점", - "address1": "서울시 마포구", - "address2": "어울마당로 35", - "description": "수제 치킨 전문점", - "imageUrl": "https://picsum.photos/id/292/640/360", - "originalHourlyPay": 13000 - }, - "href": "/shops/shop-chicken" - } - }, - "links": [ - { - "rel": "self", - "description": "공고 상세", - "method": "GET", - "href": "/notices/notice-002" - }, - { - "rel": "shop", - "description": "가게 상세", - "method": "GET", - "href": "/shops/shop-chicken" - } - ] - }, - { - "item": { - "id": "notice-003", - "hourlyPay": 9500, - "startsAt": "2023-07-10T09:00:00Z", - "workhour": 6, - "description": "오전 반찬 포장 보조 인력을 찾습니다.", - "closed": true, - "shop": { - "item": { - "id": "shop-deli", - "name": "성수 반찬가게", - "category": "식품", - "address1": "서울시 성동구", - "address2": "성수일로 55", - "description": "수제로 만든 반찬 판매점", - "imageUrl": "https://picsum.photos/id/1060/640/360", - "originalHourlyPay": 9000 - }, - "href": "/shops/shop-deli" - } - }, - "links": [ - { - "rel": "self", - "description": "공고 상세", - "method": "GET", - "href": "/notices/notice-003" - }, - { - "rel": "shop", - "description": "가게 상세", - "method": "GET", - "href": "/shops/shop-deli" - } - ] - }, - { - "item": { - "id": "notice-004", - "hourlyPay": 16000, - "startsAt": "2025-09-10T10:00:00Z", - "workhour": 5, - "description": "평일 오전 카운터 지원 인력을 모집합니다.", - "closed": false, - "shop": { - "item": { - "id": "shop-bakery", - "name": "합정 베이커리", - "category": "카페", - "address1": "서울시 마포구", - "address2": "합정역로 80", - "description": "천연 발효종으로 빵을 만드는 베이커리", - "imageUrl": "https://picsum.photos/id/1040/640/360", - "originalHourlyPay": 14000 - }, - "href": "/shops/shop-bakery" - } - }, - "links": [ - { - "rel": "self", - "description": "공고 상세", - "method": "GET", - "href": "/notices/notice-004" - }, - { - "rel": "shop", - "description": "가게 상세", - "method": "GET", - "href": "/shops/shop-bakery" - } - ] - }, - { - "item": { - "id": "notice-005", - "hourlyPay": 10000, - "startsAt": "2025-09-05T14:00:00Z", - "workhour": 3, - "description": "오후 피크 시간대 서빙 인력을 찾습니다.", - "closed": false, - "shop": { - "item": { - "id": "shop-ramen", - "name": "이태원 라멘집", - "category": "일식", - "address1": "서울시 용산구", - "address2": "이태원동 123-10", - "description": "후쿠오카식 진한 육수 라멘", - "imageUrl": "https://picsum.photos/id/1050/640/360", - "originalHourlyPay": 10000 - }, - "href": "/shops/shop-ramen" - } - }, - "links": [ - { - "rel": "self", - "description": "공고 상세", - "method": "GET", - "href": "/notices/notice-005" - }, - { - "rel": "shop", - "description": "가게 상세", - "method": "GET", - "href": "/shops/shop-ramen" - } - ] - }, - { - "item": { - "id": "notice-006", - "hourlyPay": 9000, - "startsAt": "2023-07-15T07:00:00Z", - "workhour": 4, - "description": "새벽 도넛 포장 보조 인력을 찾습니다.", - "closed": true, - "shop": { - "item": { - "id": "shop-donut", - "name": "망원 도넛 하우스", - "category": "디저트", - "address1": "서울시 마포구", - "address2": "망원로 67", - "description": "수제 도넛 전문점", - "imageUrl": "https://picsum.photos/id/1062/640/360", - "originalHourlyPay": 8500 - }, - "href": "/shops/shop-donut" - } - }, - "links": [ - { - "rel": "self", - "description": "공고 상세", - "method": "GET", - "href": "/notices/notice-006" - }, - { - "rel": "shop", - "description": "가게 상세", - "method": "GET", - "href": "/shops/shop-donut" - } - ] - } - ] -} diff --git a/src/components/ui/card/post/mockData/postWrapper.tsx b/src/components/ui/card/post/mockData/postWrapper.tsx deleted file mode 100644 index 6db0407..0000000 --- a/src/components/ui/card/post/mockData/postWrapper.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import Post from '@/components/ui/card/post/post'; -import type { PostCard } from '@/types/notice'; -import mockResponse from './mockData.json'; - -// mockData 용 페이지 - -type RawNotice = (typeof mockResponse)['items'][number]; - -const toPostCard = ({ item }: RawNotice): PostCard => { - const shop = item.shop.item; - - return { - id: item.id, - hourlyPay: item.hourlyPay, - startsAt: item.startsAt, - workhour: item.workhour, - closed: item.closed, - shopId: shop.id, - name: shop.name, - address1: shop.address1, - imageUrl: shop.imageUrl, - originalHourlyPay: shop.originalHourlyPay, - }; -}; - -const PostWrapper = () => { - const notices: PostCard[] = mockResponse.items.map(toPostCard); - return ( -
    - {notices.map(notice => ( - - ))} -
    - ); -}; - -export default PostWrapper; diff --git a/src/components/ui/card/post/post.stories.tsx b/src/components/ui/card/post/post.stories.tsx index accae4c..8aaf4d1 100644 --- a/src/components/ui/card/post/post.stories.tsx +++ b/src/components/ui/card/post/post.stories.tsx @@ -31,6 +31,7 @@ type Story = StoryObj; export const Default: Story = { args: { notice: baseNotice, + href: '', }, }; @@ -44,6 +45,7 @@ export const Expired: Story = { originalHourlyPay: 13000, shopId: 'notice-002', }, + href: '', }, }; @@ -58,5 +60,6 @@ export const Closed: Story = { startsAt: '2023-07-01T09:00:00Z', shopId: 'notice-003', }, + href: '', }, }; diff --git a/src/components/ui/card/post/post.tsx b/src/components/ui/card/post/post.tsx index f05c490..215d75d 100644 --- a/src/components/ui/card/post/post.tsx +++ b/src/components/ui/card/post/post.tsx @@ -2,7 +2,6 @@ import { cardLayout, CardStatusVariant } from '@/components/ui/card/card.styles' import CardBadge from '@/components/ui/card/cardBadge'; import CardImage from '@/components/ui/card/cardImage'; import CardInfo from '@/components/ui/card/cardInfo'; -import useAuth from '@/hooks/useAuth'; import { getTime } from '@/lib/utils/dateFormatter'; import { formatNumber } from '@/lib/utils/formatNumber'; import { getNoticeStatus } from '@/lib/utils/getNoticeStatus'; @@ -12,31 +11,19 @@ import { postFrame, postImageDimmed } from './post.styles'; interface PostProps { notice: PostCard; + href: string; } const STATUS_LABEL = { expired: '지난 공고', closed: '공고 마감', } as const; -const Post = ({ notice }: PostProps) => { - const { user } = useAuth(); - const { - id, - hourlyPay, - startsAt, - workhour, - closed, - originalHourlyPay, - imageUrl, - name, - address1, - shopId, - } = notice; +const Post = ({ notice, href = '' }: PostProps) => { + const { hourlyPay, startsAt, workhour, closed, originalHourlyPay, imageUrl, name, address1 } = + notice; const status = getNoticeStatus(closed, startsAt); const { date, startTime, endTime } = getTime(startsAt, workhour); const statusVariant: CardStatusVariant = status === 'open' ? 'open' : 'inactive'; - const href = - user && user.shop ? `/employer/shops/${shopId}/notices/${id}` : `/notices/${shopId}/${id}`; return ( diff --git a/src/components/ui/dropdown/dropdown.stories.tsx b/src/components/ui/dropdown/dropdown.stories.tsx index a70f527..9bf71cf 100644 --- a/src/components/ui/dropdown/dropdown.stories.tsx +++ b/src/components/ui/dropdown/dropdown.stories.tsx @@ -1,4 +1,3 @@ -// src/components/ui/dropdown/dropdown.stories.tsx import { ADDRESS_CODE, CATEGORY_CODE, SORT_CODE } from '@/constants/dropdown'; import type { Meta, StoryObj } from '@storybook/nextjs'; import { useState } from 'react'; 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/input/input.stories.tsx b/src/components/ui/input/input.stories.tsx index c6b68c4..13aad88 100644 --- a/src/components/ui/input/input.stories.tsx +++ b/src/components/ui/input/input.stories.tsx @@ -1,4 +1,3 @@ -// src/components/ui/input/input.stories.tsx import Button from '@/components/ui/button/button'; import type { Meta, StoryObj } from '@storybook/nextjs'; import { useState } from 'react'; diff --git a/src/components/ui/modal/modal.tsx b/src/components/ui/modal/modal.tsx index b413c02..e573caf 100644 --- a/src/components/ui/modal/modal.tsx +++ b/src/components/ui/modal/modal.tsx @@ -40,7 +40,7 @@ function useEscClose(open: boolean, onClose: () => void) { /** Header */ function ModalHeader({ variant, title }: { variant: Variant; title: string }) { return ( -
    +
    void; }) { return ( -
    +
    {secondaryText && onSecondary && ( - )} -
    diff --git a/src/components/ui/modal/notification/Notification.tsx b/src/components/ui/modal/notification/Notification.tsx index 42bded5..ae22e6c 100644 --- a/src/components/ui/modal/notification/Notification.tsx +++ b/src/components/ui/modal/notification/Notification.tsx @@ -1,7 +1,10 @@ import Icon from '@/components/ui/icon/icon'; +import useClickOutside from '@/hooks/useClickOutside'; +import useEscapeKey from '@/hooks/useEscapeKey'; +import { cn } from '@/lib/utils/cn'; import { Notice } from '@/types/notice'; import { Shop } from '@/types/shop'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import NotificationMessage from './NotificationMessage'; export interface Alert { @@ -21,52 +24,78 @@ 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; + const panelRef = useRef(null); 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() ); + // 외부 클릭 및 ESC 키로 닫기 + const close = () => (controlled ? onClose?.() : setInternalOpen(false)); + useClickOutside(panelRef, () => { + if (!open) return; + close(); + }); + useEscapeKey(e => { + if (!open) return; + e.stopPropagation(); + close(); + }); + return ( <> - {/* 제어 모드가 아니면 내부 트리거 버튼을 노출 */} {!controlled && (
    )} - {open && ( -
    -
    -
    알림 {notificationCount}개
    -
    - -
    -
    -
    - {SORTED_ALERTS.length === 0 ? ( -
    -

    알림이 없습니다.

    -
    - ) : ( -
    - {SORTED_ALERTS.map(alert => ( - - ))} -
    - )} + +
    +
    +
    알림 {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 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..37d562a 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,15 +28,16 @@ export default function Table({ limit, offset, onPageChange, + onStatusUpdate, + shopId, + noticeId, }: TableProps) { return ( -

    -
    -

    - {userRole === 'employer' ? '신청자 목록' : '신청 내역'} -

    -
    -
    + <> +

    + {userRole === 'employer' ? '신청자 목록' : '신청 내역'} +

    +
    @@ -39,7 +46,7 @@ export default function Table({ {tableData.map(row => ( - + ))}
    0 && index < headers.length - 1 && 'w-[245px]', @@ -53,16 +60,26 @@ export default function Table({
    -
    - -
    + {offset >= 2 && ( +
    + +
    + )}
    -
    + ); } 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/authProvider.tsx b/src/context/authProvider.tsx index 2c13bd7..7b7af45 100644 --- a/src/context/authProvider.tsx +++ b/src/context/authProvider.tsx @@ -34,7 +34,7 @@ export const AuthContext = createContext(null); const TOKEN_KEY = 'thejulge_token'; const USER_ID_KEY = 'thejulge_user_id'; const EXPIRES_KEY = 'thejulge_expires_at'; -const EXPIRES_DURATION_MS = 10 * 60 * 1000; // 10분 +const EXPIRES_DURATION_MS = 1000 * 60 * 1000; // 10분 /** storage helpers */ const isBrowser = () => typeof window !== 'undefined'; 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..a927a83 100644 --- a/src/context/userApplicationsProvider.tsx +++ b/src/context/userApplicationsProvider.tsx @@ -1,4 +1,4 @@ -// context/UserApplicationsProvider.tsx + import { getAllUserApplications, postApplication, putApplication } from '@/api/applications'; import useAuth from '@/hooks/useAuth'; import { ApiResponse } from '@/types/api'; @@ -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..8746d63 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 } 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,7 +98,7 @@ const EmployerNoticeEditPage = () => { if (!user?.shop) return null; return ( -
    +

    공고 편집

    @@ -129,17 +134,12 @@ const EmployerNoticeEditPage = () => { } /> - setTime(selectedTime)} - /> +