From 629d726c883064ae0087d68ca263175257df7d8d Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sat, 5 Sep 2026 19:33:18 +0900 Subject: [PATCH 01/28] =?UTF-8?q?fix(auth):=20secureFetch=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EA=B0=B1=EC=8B=A0=20=EC=9E=AC=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=EC=84=9C=20Content-Type=20=EA=B0=95=EC=A0=9C=EB=A5=BC?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 재요청에도 호출부가 넘긴 headers를 그대로 쓴다 - multipart(FormData)는 브라우저가 boundary를 붙여야 해서 강제하면 재요청이 깨진다 - JSON 호출부는 전부 직접 Content-Type을 넘기고 있어 영향 없음 --- frontend/src/apis/CLAUDE.md | 1 + frontend/src/apis/auth/secureFetch.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/apis/CLAUDE.md b/frontend/src/apis/CLAUDE.md index 784da9bf6..7254754e3 100644 --- a/frontend/src/apis/CLAUDE.md +++ b/frontend/src/apis/CLAUDE.md @@ -18,6 +18,7 @@ API는 `src/apis/utils/apiHelpers.ts`의 헬퍼 함수를 사용하는 일관된 - JWT는 localStorage에 저장 (`accessToken` 키, `src/constants/storageKeys.ts`에서 관리) - 리프레시 토큰은 쿠키로 처리 (`credentials: 'include'`) - `secureFetch()`가 1차 요청 → 401이면 `refreshAccessToken()`으로 토큰 재발급 후 재요청. refresh 실패 시 `REFRESH_FAILED` 에러 +- 재요청도 호출부가 넘긴 headers를 그대로 쓴다. `Content-Type`을 강제로 붙이지 않는 이유는 홍보 이미지 업로드(`uploadPromotionImage`)가 FormData라 브라우저가 boundary를 붙여야 하기 때문. JSON 호출부는 전부 직접 `Content-Type: application/json`을 넘긴다 - 어드민 라우트는 `PrivateRoute` 컴포넌트로 보호 ### 익명 학생 토큰 (우체통) diff --git a/frontend/src/apis/auth/secureFetch.ts b/frontend/src/apis/auth/secureFetch.ts index 94b831382..a34ff0c88 100644 --- a/frontend/src/apis/auth/secureFetch.ts +++ b/frontend/src/apis/auth/secureFetch.ts @@ -32,10 +32,11 @@ export const secureFetch = async ( input, { ...init, + // Content-Type은 호출부가 정한 값을 그대로 쓴다. + // multipart(FormData)는 브라우저가 boundary를 붙여야 해서 여기서 강제하면 재요청이 깨진다. headers: { ...(init?.headers || {}), Authorization: `Bearer ${newAccessToken}`, - 'Content-Type': 'application/json', }, credentials: 'include', }, From fa15fb40cbeef619247f318c17831dd3d5be2e22 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sat, 5 Sep 2026 19:33:18 +0900 Subject: [PATCH 02/28] =?UTF-8?q?fix(admin):=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EB=A1=9C=EA=B3=A0=EB=A5=BC=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EC=A7=80=20=EB=AA=BB=ED=95=98=EB=A9=B4=20=ED=9A=8C=EC=83=89=20?= =?UTF-8?q?=EC=9B=90=EC=9C=BC=EB=A1=9C=20=EB=8C=80=EC=B2=B4=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로고 URL이 깨지면 alt 문구가 40px 안에서 세로로 흘러내렸다 - 로고가 없거나 onError면 이미지 대신 회색 원을 그리고, 이미지는 40x40 cover로 고정 --- frontend/src/components/CLAUDE.md | 2 +- .../components/common/Header/Header.styles.ts | 14 ++++++- .../common/Header/admin/AdminProfile.test.tsx | 40 +++++++++++++++++++ .../common/Header/admin/AdminProfile.tsx | 18 ++++++--- 4 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/common/Header/admin/AdminProfile.test.tsx diff --git a/frontend/src/components/CLAUDE.md b/frontend/src/components/CLAUDE.md index 136a0d55c..86b2d0570 100644 --- a/frontend/src/components/CLAUDE.md +++ b/frontend/src/components/CLAUDE.md @@ -18,7 +18,7 @@ common/Toast/ - 스타일은 `* as Styled`로 import: `import * as Styled from './Toast.styles'` - styled-components에 넘기는 커스텀 prop은 **`$` 접두사(transient prop)**. DOM에 새어나가지 않는다. 예: `$isActive`, `$duration` -- `src/components/`에는 테스트 파일이 없다. 검증은 Storybook + `npm run typecheck`로 한다 (루트 `CLAUDE.md`의 Storybook 가이드 참고) +- `src/components/`는 기본적으로 Storybook + `npm run typecheck`로 검증한다 (루트 `CLAUDE.md`의 Storybook 가이드 참고). 시각으로 확인하기 어려운 분기(이미지 로드 실패 등)만 예외적으로 RTL 테스트를 둔다 (`common/Header/admin/AdminProfile.test.tsx`) ## 스타일 하드룰 diff --git a/frontend/src/components/common/Header/Header.styles.ts b/frontend/src/components/common/Header/Header.styles.ts index ef6f12b4a..83af6aa61 100644 --- a/frontend/src/components/common/Header/Header.styles.ts +++ b/frontend/src/components/common/Header/Header.styles.ts @@ -1,5 +1,6 @@ import styled from 'styled-components'; import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; import { Z_INDEX } from '@/styles/zIndex'; export const HEADER_HEIGHT = { @@ -140,6 +141,17 @@ export const AdminProfileText = styled.div` export const AdminProfileImage = styled.img` width: 40px; - height: auto; + height: 40px; border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +`; + +/** 로고가 없거나 불러오지 못했을 때 자리를 지키는 회색 원 */ +export const AdminProfilePlaceholder = styled.div` + width: 40px; + height: 40px; + border-radius: 50%; + background-color: ${colors.gray[300]}; + flex-shrink: 0; `; diff --git a/frontend/src/components/common/Header/admin/AdminProfile.test.tsx b/frontend/src/components/common/Header/admin/AdminProfile.test.tsx new file mode 100644 index 000000000..7ba963dbd --- /dev/null +++ b/frontend/src/components/common/Header/admin/AdminProfile.test.tsx @@ -0,0 +1,40 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import AdminProfile from './AdminProfile'; + +// apis 체인이 import.meta.env를 써서 ts-jest에서 파싱되지 않아 훅만 대체한다 +let mockLogo = ''; +jest.mock('@/hooks/Queries/useClub', () => ({ + useGetClubDetail: () => ({ data: { name: '테스트', logo: mockLogo } }), +})); +jest.mock('@/store/useAdminClubStore', () => ({ + useAdminClubId: () => ({ clubId: 'club-1' }), +})); + +describe('AdminProfile', () => { + it('로고가 없으면 이미지 대신 자리표시 원을 그린다', () => { + mockLogo = ''; + render(); + + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('로고가 있으면 이미지를 그린다', () => { + mockLogo = 'https://cdn/logo.png'; + render(); + + expect(screen.getByRole('img')).toHaveAttribute( + 'src', + 'https://cdn/logo.png', + ); + }); + + it('로고를 불러오지 못하면 alt 문구 대신 자리표시 원으로 바꾼다', () => { + mockLogo = 'https://cdn/broken.png'; + render(); + + fireEvent.error(screen.getByRole('img')); + + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/common/Header/admin/AdminProfile.tsx b/frontend/src/components/common/Header/admin/AdminProfile.tsx index 6b5b94374..1a9e3e3e3 100644 --- a/frontend/src/components/common/Header/admin/AdminProfile.tsx +++ b/frontend/src/components/common/Header/admin/AdminProfile.tsx @@ -1,4 +1,4 @@ -import DefaultMoadongLogo from '@/assets/images/logos/default_profile_image.svg'; +import { useState } from 'react'; import { useGetClubDetail } from '@/hooks/Queries/useClub'; import { useAdminClubId } from '@/store/useAdminClubStore'; import * as Styled from '../Header.styles'; @@ -7,16 +7,24 @@ const AdminProfile = () => { const { clubId } = useAdminClubId(); const { data: clubDetail } = useGetClubDetail(clubId || ''); const { name, logo } = clubDetail || {}; + // 로고 URL이 깨져 alt 문구가 세로로 흘러내리는 걸 막는다. 로고가 바뀌면 다시 시도한다. + const [brokenLogo, setBrokenLogo] = useState(null); + const showLogo = Boolean(logo) && logo !== brokenLogo; return ( {name || '관리자'}님 환영합니다! - + {showLogo ? ( + setBrokenLogo(logo ?? null)} + /> + ) : ( + + )} ); }; From 3cd109a79b8298953f0cb07a8cea503c397ac429 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sat, 5 Sep 2026 19:34:19 +0900 Subject: [PATCH 03/28] =?UTF-8?q?feat(admin):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=ED=99=8D=EB=B3=B4=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - promotion API·훅에 수정/삭제/이미지 업로드를 추가한다 - 사이드바 '홍보 관리' 탭과 목록·작성·수정 라우트를 추가한다 - 작성은 생성 → 업로드, 수정은 업로드 → PUT 순으로 이미지를 반영한다 - 심사 전 동아리는 폼을 막고 902-2와 같은 안내 문구를 보여준다 - 상세 API의 state가 설명값('활성화')이라 enum 이름과 둘 다 승인으로 본다 --- frontend/src/apis/promotion.test.ts | 121 ++++++- frontend/src/apis/promotion.ts | 57 +++- frontend/src/constants/adminFieldLimits.ts | 6 + frontend/src/constants/adminTabs.ts | 4 + frontend/src/constants/eventName.ts | 7 + frontend/src/hooks/Queries/CLAUDE.md | 8 + frontend/src/hooks/Queries/usePromotion.ts | 65 +++- frontend/src/pages/AdminPage/AdminRoutes.tsx | 10 + .../PromotionTab/PromotionEditTab.styles.ts | 159 +++++++++ .../tabs/PromotionTab/PromotionEditTab.tsx | 313 ++++++++++++++++++ .../PromotionTab/PromotionListTab.styles.ts | 218 ++++++++++++ .../PromotionTab/PromotionListTab.test.tsx | 112 +++++++ .../tabs/PromotionTab/PromotionListTab.tsx | 209 ++++++++++++ .../PromotionImageField.styles.ts | 120 +++++++ .../PromotionImageField.tsx | 120 +++++++ .../AdminPage/tabs/PromotionTab/constants.ts | 13 + .../PromotionTab/hooks/usePromotionForm.ts | 190 +++++++++++ .../PromotionTab/utils/promotionForm.test.ts | 170 ++++++++++ .../tabs/PromotionTab/utils/promotionForm.ts | 159 +++++++++ frontend/src/types/promotion.ts | 17 + 20 files changed, 2071 insertions(+), 7 deletions(-) create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.styles.ts create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.styles.ts create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.tsx create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.styles.ts create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.tsx create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/constants.ts create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts diff --git a/frontend/src/apis/promotion.test.ts b/frontend/src/apis/promotion.test.ts index 616ad35e2..b9c4f2c49 100644 --- a/frontend/src/apis/promotion.test.ts +++ b/frontend/src/apis/promotion.test.ts @@ -3,7 +3,13 @@ import { CreatePromotionArticleRequest, PromotionArticle, } from '@/types/promotion'; -import { createPromotionArticle, getPromotionArticles } from './promotion'; +import { + createPromotionArticle, + deletePromotionArticle, + getPromotionArticles, + updatePromotionArticle, + uploadPromotionImage, +} from './promotion'; jest.mock('@/constants/api', () => ({ __esModule: true, @@ -111,16 +117,15 @@ describe('promotion API', () => { clubId: 'club1', title: '새로운 홍보글', location: '서울', + latitude: 35.1, + longitude: 129.1, eventStartDate: '2024-03-01', eventEndDate: '2024-03-31', description: '홍보 내용', images: ['image1.jpg'], }; - const mockResponse = { - id: '123', - message: '생성 성공', - }; + const mockResponse = { articleId: '123' }; fetchMock.mockResponseOnce(JSON.stringify({ data: mockResponse }), { headers: { 'content-type': 'application/json' }, @@ -144,6 +149,8 @@ describe('promotion API', () => { clubId: 'club1', title: '새로운 홍보글', location: '부산', + latitude: 35.1, + longitude: 129.1, eventStartDate: '2024-03-01', eventEndDate: '2024-03-31', description: '홍보 내용', @@ -159,4 +166,108 @@ describe('promotion API', () => { ); }); }); + + describe('updatePromotionArticle', () => { + const payload: CreatePromotionArticleRequest = { + clubId: 'club1', + title: '수정된 홍보글', + location: '부산', + latitude: 35.1, + longitude: 129.1, + eventStartDate: '2024-03-01', + eventEndDate: '2024-03-31', + description: '수정 내용', + images: ['image1.jpg'], + }; + + it('PUT으로 바디를 보내고 응답 없이 끝난다', async () => { + fetchMock.mockResponseOnce('', { status: 200 }); + + await expect( + updatePromotionArticle('123', payload), + ).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledWith( + `${API_BASE_URL}/api/promotion/123`, + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify(payload), + }), + ); + }); + + it('실패 시 기본 문구로 던지고 서버 문구는 data에 남긴다', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ + statusCode: '902-2', + message: '심사가 완료된 동아리만 홍보 게시글을 작성할 수 있습니다.', + }), + { status: 403 }, + ); + + await expect(updatePromotionArticle('123', payload)).rejects.toThrow( + '홍보게시판 글 수정에 실패했습니다.', + ); + }); + }); + + describe('deletePromotionArticle', () => { + it('DELETE로 요청한다', async () => { + fetchMock.mockResponseOnce('', { status: 200 }); + + await expect(deletePromotionArticle('123')).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledWith( + `${API_BASE_URL}/api/promotion/123`, + expect.objectContaining({ method: 'DELETE' }), + ); + }); + + it('404면 에러를 던진다', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ statusCode: '902-1', message: '없는 글' }), + { status: 404 }, + ); + + await expect(deletePromotionArticle('missing')).rejects.toThrow( + '홍보게시판 글 삭제에 실패했습니다.', + ); + }); + }); + + describe('uploadPromotionImage', () => { + it('multipart의 file 필드로 올리고 imageUrl을 돌려준다', async () => { + fetchMock.mockResponseOnce( + JSON.stringify({ data: { imageUrl: 'https://cdn/a.png' } }), + { headers: { 'content-type': 'application/json' } }, + ); + const file = new File(['x'], 'a.png', { type: 'image/png' }); + + const result = await uploadPromotionImage('123', file); + + expect(result).toEqual({ imageUrl: 'https://cdn/a.png' }); + + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe(`${API_BASE_URL}/api/promotion/123/upload`); + expect(options?.method).toBe('POST'); + expect(options?.body).toBeInstanceOf(FormData); + // jest-fetch-mock이 FormData 값을 문자열로 바꿔 두므로 필드 존재만 확인한다 + expect((options?.body as FormData).has('file')).toBe(true); + // multipart boundary는 브라우저가 붙여야 하므로 Content-Type을 직접 넣지 않는다 + expect( + (options?.headers as Record)['Content-Type'], + ).toBeUndefined(); + }); + + it('실패 시 에러를 던진다', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ message: '실패' }), { + status: 500, + }); + const file = new File(['x'], 'a.png', { type: 'image/png' }); + + await expect(uploadPromotionImage('123', file)).rejects.toThrow( + '홍보 이미지 업로드에 실패했습니다.', + ); + }); + }); }); diff --git a/frontend/src/apis/promotion.ts b/frontend/src/apis/promotion.ts index 612fb9ceb..158bcae9f 100644 --- a/frontend/src/apis/promotion.ts +++ b/frontend/src/apis/promotion.ts @@ -3,7 +3,10 @@ import { festivalMock } from '@/mocks/data/festivalMock'; import { sortPromotions } from '@/pages/PromotionPage/utils/sortPromotions'; import { CreatePromotionArticleRequest, + CreatePromotionArticleResponse, PromotionArticle, + PromotionImageUploadResponse, + UpdatePromotionArticleRequest, } from '@/types/promotion'; import { secureFetch } from './auth/secureFetch'; import { handleResponse } from './utils/apiHelpers'; @@ -41,5 +44,57 @@ export const createPromotionArticle = async ( body: JSON.stringify(payload), }); - return handleResponse(response, '홍보게시판 글 추가에 실패했습니다.'); + return handleResponse( + response, + '홍보게시판 글 추가에 실패했습니다.', + ); +}; + +export const updatePromotionArticle = async ( + articleId: string, + payload: UpdatePromotionArticleRequest, +) => { + const response = await secureFetch( + `${API_BASE_URL}/api/promotion/${articleId}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }, + ); + + await handleResponse(response, '홍보게시판 글 수정에 실패했습니다.'); +}; + +export const deletePromotionArticle = async (articleId: string) => { + const response = await secureFetch( + `${API_BASE_URL}/api/promotion/${articleId}`, + { method: 'DELETE' }, + ); + + await handleResponse(response, '홍보게시판 글 삭제에 실패했습니다.'); +}; + +/** + * 이미지는 글을 먼저 만들어 articleId를 받은 뒤 올린다. + * 서버가 업로드된 URL을 해당 글의 images에 바로 추가하므로 생성 직후에는 PUT이 필요 없다. + */ +export const uploadPromotionImage = async (articleId: string, file: File) => { + const formData = new FormData(); + formData.append('file', file); + + const response = await secureFetch( + `${API_BASE_URL}/api/promotion/${articleId}/upload`, + { + method: 'POST', + body: formData, + }, + ); + + return handleResponse( + response, + '홍보 이미지 업로드에 실패했습니다.', + ); }; diff --git a/frontend/src/constants/adminFieldLimits.ts b/frontend/src/constants/adminFieldLimits.ts index c63135b2a..3a9fb68f7 100644 --- a/frontend/src/constants/adminFieldLimits.ts +++ b/frontend/src/constants/adminFieldLimits.ts @@ -16,3 +16,9 @@ export const RECRUIT_TARGET_MAX = 10; // 계정 관리 (AccountEditTab) export const PASSWORD_MAX = 20; + +// 홍보 게시글 관리 (PromotionTab) +export const PROMOTION_TITLE_MAX = 50; +export const PROMOTION_LOCATION_MAX = 50; +export const PROMOTION_DESCRIPTION_MAX = 1000; +export const PROMOTION_IMAGE_MAX_COUNT = 10; diff --git a/frontend/src/constants/adminTabs.ts b/frontend/src/constants/adminTabs.ts index a6618e4b0..1abf4ae24 100644 --- a/frontend/src/constants/adminTabs.ts +++ b/frontend/src/constants/adminTabs.ts @@ -22,6 +22,10 @@ export const ADMIN_TABS: TabCategory[] = [ category: '모집 정보', items: [{ label: '모집 정보 수정', path: '/admin/recruit-edit' }], }, + { + category: '홍보 관리', + items: [{ label: '홍보 게시글 관리', path: '/admin/promotion' }], + }, { category: '지원 관리', items: [ diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts index 13e901bfe..339c50a9a 100644 --- a/frontend/src/constants/eventName.ts +++ b/frontend/src/constants/eventName.ts @@ -169,6 +169,11 @@ export const ADMIN_EVENT = { AI_DRAFT_GENERATION_FAILED: 'AI 지원서 초안 생성 실패', APPLICATION_FORM_SAVED: '지원서 저장', + // 홍보 게시글 관리 + PROMOTION_CREATE_BUTTON_CLICKED: '홍보 게시글 작성 버튼클릭', + PROMOTION_SAVE_BUTTON_CLICKED: '홍보 게시글 저장 버튼클릭', + PROMOTION_DELETE_BUTTON_CLICKED: '홍보 게시글 삭제 버튼클릭', + // 비밀번호 수정 PASSWORD_CHANGE_BUTTON_CLICKED: '비밀번호 변경 버튼클릭', NEW_PASSWORD_CLEAR_BUTTON_CLICKED: '새 비밀번호 입력 초기화 버튼클릭', @@ -210,6 +215,8 @@ export const PAGE_VIEW = { ADMIN_STATISTICS_PAGE: '동아리 통계 페이지', ADMIN_ACCOUNT_EDIT_PAGE: '관리자 계정 수정 페이지', ADMIN_CALENDAR_PAGE: '동아리 일정 관리 페이지', + ADMIN_PROMOTION_LIST_PAGE: '홍보 게시글 관리 페이지', + ADMIN_PROMOTION_EDIT_PAGE: '홍보 게시글 작성 페이지', } as const; export const PAGE_NAME = { diff --git a/frontend/src/hooks/Queries/CLAUDE.md b/frontend/src/hooks/Queries/CLAUDE.md index 6fa2b1ead..bcd93b1d0 100644 --- a/frontend/src/hooks/Queries/CLAUDE.md +++ b/frontend/src/hooks/Queries/CLAUDE.md @@ -52,3 +52,11 @@ Google OAuth 동의 화면이 테스트 모드면 refresh token이 7일 뒤 만 `convertGoogleDriveUrl`이 null을 받으면 내부 `try/catch`가 삼켜 `console.error`만 남기고 null을 그대로 돌려주니, 무가드 프로퍼티 접근을 새로 추가하지 말 것. + +## 홍보 게시글 관리 (`usePromotion`) + +관리자 CRUD는 목록 쿼리 하나(`queryKeys.promotion.list()`)만 쓰고 생성·수정·삭제·업로드 뮤테이션이 모두 그 키를 무효화한다. 상세 조회 API가 없어 수정 화면도 목록에서 `id`로 찾는다. + +- 이미지는 글이 있어야 올릴 수 있다(`POST /api/promotion/{id}/upload`). 서버가 업로드된 URL을 글의 `images`에 `$addToSet`으로 바로 넣으므로 **작성은 생성 → 업로드로 끝**, **수정은 업로드 → PUT(기존 유지분 + 새 URL)** 순서다. 순서를 뒤집으면 PUT이 새 URL을 모르거나 삭제한 이미지를 서버가 다시 살린다 +- 수정 PUT은 `images`가 1개 이상이어야 한다(`@NotEmpty`). 생성은 빈 배열 허용 +- 심사 전 동아리는 서버가 403(902-2)로 막는다. 화면은 요청 전에 `ClubDetail.state`로 먼저 막고 같은 문구를 보여준다. 주의: 상세 API의 `state`는 enum 이름이 아니라 설명값(`'활성화'`/`'비활성화'`)이고 목록 API는 `'AVAILABLE'`이다. 판정은 `PromotionTab/constants.ts`의 `isClubApproved`로만 한다 diff --git a/frontend/src/hooks/Queries/usePromotion.ts b/frontend/src/hooks/Queries/usePromotion.ts index 0a2d2738a..e8ce2fa37 100644 --- a/frontend/src/hooks/Queries/usePromotion.ts +++ b/frontend/src/hooks/Queries/usePromotion.ts @@ -1,10 +1,17 @@ import { useLocation } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { createPromotionArticle, getPromotionArticles } from '@/apis/promotion'; +import { + createPromotionArticle, + deletePromotionArticle, + getPromotionArticles, + updatePromotionArticle, + uploadPromotionImage, +} from '@/apis/promotion'; import { queryKeys } from '@/constants/queryKeys'; import { CreatePromotionArticleRequest, PromotionArticle, + UpdatePromotionArticleRequest, } from '@/types/promotion'; export const useGetPromotionArticles = () => { @@ -37,3 +44,59 @@ export const useCreatePromotionArticle = () => { }, }); }; + +export const useUpdatePromotionArticle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + articleId, + payload, + }: { + articleId: string; + payload: UpdatePromotionArticleRequest; + }) => updatePromotionArticle(articleId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.promotion.list(), + }); + }, + onError: (error) => { + console.error('Error updating promotion article:', error); + }, + }); +}; + +export const useDeletePromotionArticle = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (articleId: string) => deletePromotionArticle(articleId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.promotion.list(), + }); + }, + onError: (error) => { + console.error('Error deleting promotion article:', error); + }, + }); +}; + +/** 서버가 업로드된 URL을 글의 images에 바로 추가하므로 목록도 함께 무효화한다 */ +export const useUploadPromotionImage = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ articleId, file }: { articleId: string; file: File }) => + uploadPromotionImage(articleId, file), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.promotion.list(), + }); + }, + onError: (error) => { + console.error('Error uploading promotion image:', error); + }, + }); +}; diff --git a/frontend/src/pages/AdminPage/AdminRoutes.tsx b/frontend/src/pages/AdminPage/AdminRoutes.tsx index 407c41ca9..92a920b3d 100644 --- a/frontend/src/pages/AdminPage/AdminRoutes.tsx +++ b/frontend/src/pages/AdminPage/AdminRoutes.tsx @@ -11,6 +11,8 @@ import CalendarSyncTab from '@/pages/AdminPage/tabs/CalendarSyncTab/CalendarSync import ClubInfoEditTab from '@/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab'; import ClubIntroEditTab from '@/pages/AdminPage/tabs/ClubIntroEditTab/ClubIntroEditTab'; import PhotoEditTab from '@/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab'; +import PromotionEditTab from '@/pages/AdminPage/tabs/PromotionTab/PromotionEditTab'; +import PromotionListTab from '@/pages/AdminPage/tabs/PromotionTab/PromotionListTab'; import RecruitEditTab from '@/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab'; import SettingsTab from '@/pages/AdminPage/tabs/SettingsTab/SettingsTab'; import StatisticsTab from '@/pages/AdminPage/tabs/StatisticsTab/StatisticsTab'; @@ -36,6 +38,14 @@ export default function AdminRoutes() { } /> } /> + {/* 홍보 관리 */} + } /> + } /> + } + /> + {/* 지원 관리 */} } /> { + const { articleId } = useParams<{ articleId: string }>(); + const navigate = useNavigate(); + const trackEvent = useMixpanelTrack(); + const { isMobile, isTablet } = useDevice(); + const isCompact = isMobile || isTablet; + const clubDetail = useOutletContext(); + const isApproved = isClubApproved(clubDetail.state); + + useTrackPageView(PAGE_VIEW.ADMIN_PROMOTION_EDIT_PAGE); + + const { data: articles, isLoading } = useGetPromotionArticles(); + const article = articleId + ? articles?.find( + (item) => item.id === articleId && item.clubId === clubDetail.id, + ) + : undefined; + + const form = usePromotionForm({ clubId: clubDetail.id, article }); + const { values, setField } = form; + const [toastMessage, setToastMessage] = useState(null); + + const isEdit = Boolean(articleId); + const isFormDisabled = !isApproved || form.isSaving; + const selectedBuilding = findBuildingByCoordinates(values.coordinates); + // 개발자가 좌표를 직접 넣은 글은 건물 목록과 안 맞을 수 있다. 그 좌표는 유지하고 표시만 따로 한다. + const buildingSelectValue = selectedBuilding + ? selectedBuilding.value + : values.coordinates + ? CUSTOM_BUILDING_VALUE + : ''; + + const handleBuildingChange = (e: React.ChangeEvent) => { + const option = BUILDING_OPTIONS.find((o) => o.value === e.target.value); + if (!option) return; + setField('coordinates', option.coordinates); + if (!values.location.trim()) setField('location', option.label); + }; + + const handleStartChange = (date: Date | null) => { + setField('eventStart', date); + if (date && values.eventEnd && date > values.eventEnd) + setField('eventEnd', date); + }; + + const handleEndChange = (date: Date | null) => { + setField('eventEnd', date); + if (date && values.eventStart && date < values.eventStart) + setField('eventStart', date); + }; + + const goToList = (message?: string) => + navigate(PROMOTION_LIST_PATH, { + state: message ? { toastMessage: message } : undefined, + }); + + const handleSave = async () => { + trackEvent(ADMIN_EVENT.PROMOTION_SAVE_BUTTON_CLICKED, { mode: form.mode }); + const result = await form.save(); + + if (result.status === 'error') { + setToastMessage(result.message); + return; + } + if (result.status === 'partial') { + const message = `글은 저장됐지만 이미지 ${result.failedCount}장 업로드에 실패했어요. 다시 올려주세요.`; + if (isEdit) { + setToastMessage(message); + } else { + navigate(`${PROMOTION_LIST_PATH}/${result.articleId}/edit`, { + replace: true, + state: { toastMessage: message }, + }); + } + return; + } + goToList( + isEdit + ? '홍보 게시글이 수정되었습니다.' + : '홍보 게시글이 등록되었습니다.', + ); + }; + + const title = isEdit ? '홍보 게시글 수정' : '홍보 게시글 작성'; + + if (isEdit && isLoading) return ; + + if (isEdit && !article) { + return ( + + {isCompact && goToList()} />} + + 게시글을 찾을 수 없어요 + + 삭제됐거나 우리 동아리의 글이 아니에요. + + + + + ); + } + + const fields = ( + <> + {!isApproved && ( + + {PROMOTION_NOT_APPROVED_MESSAGE} + + )} + + setField('title', e.target.value)} + onClear={() => setField('title', '')} + maxLength={PROMOTION_TITLE_MAX} + disabled={isFormDisabled} + /> + +
+ 지도 위치 + + + {buildingSelectValue === CUSTOM_BUILDING_VALUE && ( + + )} + {BUILDING_OPTIONS.map((option) => ( + + ))} + + + 선택한 건물 위치가 홍보글 상세의 지도에 표시돼요. + + {values.coordinates && ( + + + + )} +
+ + setField('location', e.target.value)} + onClear={() => setField('location', '')} + maxLength={PROMOTION_LOCATION_MAX} + disabled={isFormDisabled} + /> + +
+ 행사 기간 + {isCompact ? ( + + + handleStartChange(fromDateTimeLocalValue(e.target.value)) + } + disabled={isFormDisabled} + /> + + handleEndChange(fromDateTimeLocalValue(e.target.value)) + } + disabled={isFormDisabled} + /> + + ) : ( + + )} +
+ + setField('description', e.target.value)} + maxLength={PROMOTION_DESCRIPTION_MAX} + showMaxChar + disabled={isFormDisabled} + /> + + + + ); + + const saveLabel = form.isSaving ? '저장 중…' : '저장하기'; + + return ( + + {isCompact ? ( + <> + goToList()} /> + {fields} + {isApproved && ( + + {saveLabel} + + )} + + ) : ( + + + goToList()}> + 취소 + + {isApproved && ( + + )} + + } + /> + {fields} + + )} + + setToastMessage(null)} + message={toastMessage ?? ''} + backgroundColor={colors.primary[900]} + /> + + ); +}; + +export default PromotionEditTab; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.styles.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.styles.ts new file mode 100644 index 000000000..8b9116e23 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.styles.ts @@ -0,0 +1,218 @@ +import styled, { css } from 'styled-components'; +import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; +import { setTypography, typography } from '@/styles/theme/typography'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + gap: 60px; + + /* WebviewTopBar는 tablet에서 margin: 0 auto라 flex column 자식이면 내용 너비로 줄어든다. block으로 둔다 */ + ${media.tablet} { + display: block; + width: 100%; + max-width: 500px; + min-height: 100vh; + margin: 0 auto; + background-color: ${colors.base.white}; + box-shadow: 0px 2px 12px rgba(0, 0, 0, 0.04); + } + + ${media.mobile} { + max-width: 100%; + margin: 0; + box-shadow: none; + } +`; + +export const CompactBody = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px 20px 40px; +`; + +export const CompactHeader = styled.div` + display: flex; + justify-content: flex-end; +`; + +export const Notice = styled.div` + padding: 14px 18px; + border-radius: 12px; + background: ${colors.gray[100]}; + border: 1px solid ${colors.gray[300]}; + ${setTypography(typography.paragraph.p5)} + color: ${colors.gray[800]}; +`; + +export const AddButton = styled.button` + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border: none; + border-radius: 20px; + background-color: ${colors.gray[100]}; + ${setTypography(typography.paragraph.p5)} + color: ${colors.base.black}; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: ${colors.gray[200]}; + } +`; + +export const PlusIcon = styled.img` + width: 19px; + height: 19px; +`; + +export const CardList = styled.ul` + display: flex; + flex-direction: column; + gap: 12px; + list-style: none; + padding: 0; + margin: 0; +`; + +export const Card = styled.li` + display: flex; + align-items: center; + gap: 16px; + padding: 14px 16px; + border: 1px solid ${colors.gray[400]}; + border-radius: 20px; + background: ${colors.base.white}; + + ${media.tablet} { + flex-wrap: wrap; + gap: 12px; + padding: 12px; + } +`; + +export const Thumbnail = styled.button` + flex-shrink: 0; + width: 96px; + height: 96px; + padding: 0; + border: none; + border-radius: 12px; + overflow: hidden; + background: ${colors.gray[100]}; + cursor: pointer; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + ${media.tablet} { + width: 72px; + height: 72px; + } +`; + +export const ThumbnailPlaceholder = styled.span` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + ${setTypography(typography.paragraph.p7)} + color: ${colors.gray[600]}; +`; + +export const CardBody = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; +`; + +export const CardTitle = styled.p` + ${setTypography(typography.paragraph.p2)} + color: ${colors.gray[900]}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const CardMeta = styled.p` + ${setTypography(typography.paragraph.p6)} + color: ${colors.gray[700]}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const CardActions = styled.div` + display: flex; + gap: 8px; + flex-shrink: 0; + + ${media.tablet} { + width: 100%; + justify-content: flex-end; + } +`; + +export const ActionButton = styled.button<{ $danger?: boolean }>` + height: 34px; + padding: 0 14px; + border: 1px solid ${colors.gray[400]}; + border-radius: 8px; + background: ${colors.base.white}; + ${setTypography(typography.button.button1)} + color: ${colors.gray[800]}; + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover:not(:disabled) { + background: ${colors.gray[100]}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + ${({ $danger }) => + $danger && + css` + color: #ef4444; + border-color: #fca5a5; + + &:hover:not(:disabled) { + background: #fff1f2; + } + `} +`; + +export const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 60px 20px; + border: 1px dashed ${colors.gray[400]}; + border-radius: 20px; + text-align: center; +`; + +export const EmptyTitle = styled.p` + ${setTypography(typography.paragraph.p2)} + color: ${colors.primary[900]}; +`; + +export const EmptyDescription = styled.p` + ${setTypography(typography.paragraph.p5)} + color: ${colors.gray[700]}; +`; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx new file mode 100644 index 000000000..d4ee02740 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx @@ -0,0 +1,112 @@ +import '@testing-library/jest-dom'; +import { MemoryRouter, Outlet, Route, Routes } from 'react-router-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { PromotionArticle } from '@/types/promotion'; +import PromotionListTab from './PromotionListTab'; + +// apis 체인이 import.meta.env를 써서 ts-jest에서 파싱되지 않아 훅만 대체한다 +const mockDelete = jest.fn(); +const mockArticles: PromotionArticle[] = []; +jest.mock('@/hooks/Queries/usePromotion', () => ({ + useGetPromotionArticles: () => ({ + data: mockArticles, + isLoading: false, + isError: false, + error: null, + }), + useDeletePromotionArticle: () => ({ mutate: mockDelete, isPending: false }), +})); +jest.mock('@/hooks/Mixpanel/useMixpanelTrack', () => () => jest.fn()); +jest.mock('@/hooks/Mixpanel/useTrackPageView', () => () => {}); +jest.mock('@/hooks/useDevice', () => () => ({ + isMobile: false, + isTablet: false, + isLaptop: false, + isDesktop: true, +})); + +const makeArticle = ( + overrides: Partial & + Pick, +): PromotionArticle => ({ + clubName: '동아리', + title: `제목 ${overrides.id}`, + location: '한울관(E31)', + latitude: 35.13, + longitude: 129.1, + eventStartDate: '2026-04-01T01:00:00Z', + eventEndDate: '2026-04-01T03:00:00Z', + description: '설명', + images: [], + ...overrides, +}); + +// 상세 API는 state를 설명값('활성화'/'비활성화')으로 준다 +const renderTab = (state = '활성화') => { + render( + + + } + > + } /> + + + , + ); +}; + +beforeEach(() => { + mockArticles.length = 0; + mockDelete.mockReset(); + const root = document.createElement('div'); + root.id = 'modal-root'; + document.body.appendChild(root); +}); + +afterEach(() => { + document.getElementById('modal-root')?.remove(); +}); + +describe('PromotionListTab', () => { + it('내 동아리 글만 보여준다', () => { + mockArticles.push( + makeArticle({ id: 'mine', clubId: 'my-club' }), + makeArticle({ id: 'other', clubId: 'other-club' }), + ); + renderTab(); + + expect(screen.getByText('제목 mine')).toBeInTheDocument(); + expect(screen.queryByText('제목 other')).not.toBeInTheDocument(); + }); + + it('심사 전 동아리는 작성 버튼 대신 안내 문구를 보여준다', () => { + renderTab('비활성화'); + + expect( + screen.queryByRole('button', { name: /새 게시글 작성/ }), + ).not.toBeInTheDocument(); + expect( + screen.getByText( + '심사가 완료된 동아리만 홍보 게시글을 작성할 수 있습니다.', + ), + ).toBeInTheDocument(); + }); + + it('삭제는 확인창을 거친 뒤에만 요청한다', () => { + mockArticles.push(makeArticle({ id: 'mine', clubId: 'my-club' })); + const confirmSpy = jest.spyOn(window, 'confirm'); + renderTab(); + + confirmSpy.mockReturnValueOnce(false); + fireEvent.click(screen.getByRole('button', { name: '삭제' })); + expect(mockDelete).not.toHaveBeenCalled(); + + confirmSpy.mockReturnValueOnce(true); + fireEvent.click(screen.getByRole('button', { name: '삭제' })); + expect(mockDelete).toHaveBeenCalledWith('mine', expect.any(Object)); + + confirmSpy.mockRestore(); + }); +}); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.tsx new file mode 100644 index 000000000..2619de32a --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate, useOutletContext } from 'react-router-dom'; +import { getServerErrorMessage } from '@/apis/utils/getServerErrorMessage'; +import Plus from '@/assets/images/icons/Plus.svg'; +import Spinner from '@/components/common/Spinner/Spinner'; +import Toast from '@/components/common/Toast/Toast'; +import WebviewTopBar from '@/components/common/WebviewTopBar/WebviewTopBar'; +import { ADMIN_EVENT, PAGE_VIEW } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; +import { + useDeletePromotionArticle, + useGetPromotionArticles, +} from '@/hooks/Queries/usePromotion'; +import useDevice from '@/hooks/useDevice'; +import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; +import { colors } from '@/styles/theme/colors'; +import { ClubDetail } from '@/types/club'; +import { PromotionArticle } from '@/types/promotion'; +import { formatKSTDateTimeFull } from '@/utils/formatKSTDateTime'; +import { + isClubApproved, + PROMOTION_LIST_PATH, + PROMOTION_NOT_APPROVED_MESSAGE, +} from './constants'; +import * as Styled from './PromotionListTab.styles'; + +const formatPeriod = (article: PromotionArticle) => + `${formatKSTDateTimeFull(article.eventStartDate)} ~ ${formatKSTDateTimeFull(article.eventEndDate)}`; + +const PromotionListTab = () => { + const navigate = useNavigate(); + const location = useLocation(); + const trackEvent = useMixpanelTrack(); + const { isMobile, isTablet } = useDevice(); + const isCompact = isMobile || isTablet; + const clubDetail = useOutletContext(); + const isApproved = isClubApproved(clubDetail.state); + + useTrackPageView(PAGE_VIEW.ADMIN_PROMOTION_LIST_PAGE); + + const { + data: articles, + isLoading, + isError, + error, + } = useGetPromotionArticles(); + const { mutate: deleteArticle, isPending: isDeleting } = + useDeletePromotionArticle(); + + // 작성·수정 화면에서 저장 후 넘어오면서 건넨 문구를 첫 렌더에 띄우고, + // 뒤로가기로 돌아왔을 때 다시 뜨지 않도록 history state는 비운다 + const incomingToast = (location.state as { toastMessage?: string } | null) + ?.toastMessage; + const [toastMessage, setToastMessage] = useState( + incomingToast ?? null, + ); + useEffect(() => { + if (!incomingToast) return; + navigate(location.pathname, { replace: true, state: null }); + }, [incomingToast, location.pathname, navigate]); + + const myArticles = (articles ?? []).filter( + (article) => article.clubId === clubDetail.id, + ); + + const handleCreate = () => { + trackEvent(ADMIN_EVENT.PROMOTION_CREATE_BUTTON_CLICKED); + navigate(`${PROMOTION_LIST_PATH}/new`); + }; + + const handleEdit = (articleId: string) => + navigate(`${PROMOTION_LIST_PATH}/${articleId}/edit`); + + const handleDelete = (article: PromotionArticle) => { + trackEvent(ADMIN_EVENT.PROMOTION_DELETE_BUTTON_CLICKED); + if ( + !window.confirm( + `'${article.title}' 게시글을 삭제하시겠습니까?\n삭제된 게시글은 홍보게시판에서 사라집니다.`, + ) + ) { + return; + } + deleteArticle(article.id, { + onSuccess: () => setToastMessage('홍보 게시글이 삭제되었습니다.'), + onError: (deleteError) => + setToastMessage( + getServerErrorMessage( + deleteError, + '홍보 게시글 삭제에 실패했습니다.', + ), + ), + }); + }; + + const renderBody = () => { + if (isLoading) return ; + if (isError) return
오류가 발생했습니다: {error.message}
; + + if (myArticles.length === 0) { + return ( + + + 아직 작성한 홍보 게시글이 없어요 + + + {isApproved + ? '행사·공연·전시 소식을 올려 학우들에게 알려보세요.' + : PROMOTION_NOT_APPROVED_MESSAGE} + + + ); + } + + return ( + + {myArticles.map((article) => ( + + handleEdit(article.id)} + > + {article.images[0] ? ( + + ) : ( + + 이미지 없음 + + )} + + + + {article.title} + {article.location} + {formatPeriod(article)} + + + + handleEdit(article.id)} + > + 수정 + + handleDelete(article)} + > + 삭제 + + + + ))} + + ); + }; + + const createButton = isApproved && ( + + 새 게시글 작성 + + ); + + return ( + + {isCompact ? ( + <> + navigate('/admin')} + /> + + {!isApproved && myArticles.length > 0 && ( + + {PROMOTION_NOT_APPROVED_MESSAGE} + + )} + {createButton} + {renderBody()} + + + ) : ( + + + + {!isApproved && myArticles.length > 0 && ( + + {PROMOTION_NOT_APPROVED_MESSAGE} + + )} + {renderBody()} + + + )} + + setToastMessage(null)} + message={toastMessage ?? ''} + backgroundColor={colors.primary[900]} + /> + + ); +}; + +export default PromotionListTab; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.styles.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.styles.ts new file mode 100644 index 000000000..4215739c2 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.styles.ts @@ -0,0 +1,120 @@ +import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; + +export const Header = styled.div` + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 8px; +`; + +export const Label = styled.p` + font-size: 1.125rem; + font-weight: 600; +`; + +export const Count = styled.span` + font-size: 0.875rem; + color: ${colors.gray[600]}; +`; + +export const Grid = styled.div` + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + + ${media.tablet} { + grid-template-columns: repeat(3, 1fr); + gap: 8px; + } +`; + +export const Item = styled.div` + position: relative; + aspect-ratio: 1; + border-radius: 12px; + overflow: hidden; + background: ${colors.gray[100]}; +`; + +export const Photo = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + display: block; +`; + +export const PendingBadge = styled.span` + position: absolute; + left: 8px; + bottom: 8px; + padding: 2px 8px; + border-radius: 999px; + background: rgba(17, 17, 17, 0.7); + color: ${colors.base.white}; + font-size: 0.75rem; + font-weight: 500; +`; + +export const RemoveButton = styled.button` + position: absolute; + top: 6px; + right: 6px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + cursor: pointer; + + svg { + width: 14px; + height: 14px; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +`; + +export const AddTile = styled.button` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + aspect-ratio: 1; + border: 2px dashed ${colors.gray[400]}; + border-radius: 12px; + background: ${colors.gray[50]}; + color: ${colors.gray[600]}; + font-size: 0.875rem; + cursor: pointer; + transition: border-color 0.15s ease; + + span:first-child { + font-size: 1.5rem; + line-height: 1; + } + + &:hover:not(:disabled) { + border-color: ${colors.gray[600]}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +export const HelperText = styled.p` + margin-top: 8px; + font-size: 0.8125rem; + color: ${colors.gray[600]}; +`; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.tsx new file mode 100644 index 000000000..d7db1a0a4 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.tsx @@ -0,0 +1,120 @@ +import { useRef } from 'react'; +import ClearButtonIcon from '@/assets/images/icons/dark_clear_button_icon.svg?react'; +import { PROMOTION_IMAGE_MAX_COUNT } from '@/constants/adminFieldLimits'; +import { ALLOWED_IMAGE_TYPES, MAX_FILE_SIZE } from '@/constants/uploadLimit'; +import { LocalImage } from '../../utils/promotionForm'; +import * as Styled from './PromotionImageField.styles'; + +interface PromotionImageFieldProps { + existingImages: string[]; + localFiles: LocalImage[]; + disabled?: boolean; + onAddFiles: (files: File[]) => void; + onRemoveExisting: (url: string) => void; + onRemoveLocal: (index: number) => void; + /** 파일 제한에 걸렸을 때 안내 문구를 띄운다 */ + onReject: (message: string) => void; +} + +const PromotionImageField = ({ + existingImages, + localFiles, + disabled = false, + onAddFiles, + onRemoveExisting, + onRemoveLocal, + onReject, +}: PromotionImageFieldProps) => { + const inputRef = useRef(null); + + const totalCount = existingImages.length + localFiles.length; + const isFull = totalCount >= PROMOTION_IMAGE_MAX_COUNT; + + const handleFilesSelected = (e: React.ChangeEvent) => { + const selected = Array.from(e.target.files ?? []); + e.target.value = ''; + if (selected.length === 0) return; + + const oversized = selected.find((file) => file.size > MAX_FILE_SIZE); + if (oversized) { + onReject(`${oversized.name}의 용량이 10MB를 초과했습니다.`); + return; + } + + const remaining = PROMOTION_IMAGE_MAX_COUNT - totalCount; + if (selected.length > remaining) { + onReject( + `이미지는 최대 ${PROMOTION_IMAGE_MAX_COUNT}장까지 등록할 수 있습니다.`, + ); + } + onAddFiles(selected.slice(0, Math.max(remaining, 0))); + }; + + return ( +
+ + 행사 이미지 + + {totalCount}/{PROMOTION_IMAGE_MAX_COUNT} + + + + + {existingImages.map((url) => ( + + + onRemoveExisting(url)} + > + + + + ))} + + {localFiles.map(({ previewUrl }, index) => ( + + + 업로드 예정 + onRemoveLocal(index)} + > + + + + ))} + + {!isFull && ( + inputRef.current?.click()} + > + + + 이미지 추가 + + )} + + + + JPG·PNG·WebP 등 이미지 파일, 장당 10MB 이하. 저장할 때 함께 업로드돼요. + + + +
+ ); +}; + +export default PromotionImageField; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/constants.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/constants.ts new file mode 100644 index 000000000..41a61996a --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/constants.ts @@ -0,0 +1,13 @@ +export const PROMOTION_LIST_PATH = '/admin/promotion'; + +/** 백엔드 902-2와 같은 문구. 심사 전 동아리는 서버에서도 403으로 막힌다 */ +export const PROMOTION_NOT_APPROVED_MESSAGE = + '심사가 완료된 동아리만 홍보 게시글을 작성할 수 있습니다.'; + +/** + * 심사 완료 여부. 상세 API(`GET /api/club/{id}`)는 state를 enum 이름이 아니라 + * 설명값('활성화'/'비활성화')으로 내려주고, 목록 API는 'AVAILABLE'을 준다. + * 둘 다 받아 백엔드가 나중에 이름으로 통일해도 깨지지 않게 한다. + */ +export const isClubApproved = (state: string | undefined) => + state === '활성화' || state === 'AVAILABLE'; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts new file mode 100644 index 000000000..091cb88bf --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts @@ -0,0 +1,190 @@ +import { useEffect, useRef, useState } from 'react'; +import { getServerErrorMessage } from '@/apis/utils/getServerErrorMessage'; +import { + useCreatePromotionArticle, + useUpdatePromotionArticle, + useUploadPromotionImage, +} from '@/hooks/Queries/usePromotion'; +import { PromotionArticle } from '@/types/promotion'; +import { + articleToFormValues, + buildPromotionPayload, + EMPTY_PROMOTION_FORM, + PromotionFormValues, + validatePromotionForm, +} from '../utils/promotionForm'; + +export type SaveResult = + | { status: 'success'; articleId: string } + /** 글은 저장됐지만 일부 이미지 업로드가 실패해 수정 화면에서 다시 올려야 하는 경우 */ + | { status: 'partial'; articleId: string; failedCount: number } + | { status: 'error'; message: string }; + +interface UsePromotionFormParams { + clubId: string; + /** 수정 모드면 대상 글, 작성 모드면 undefined */ + article?: PromotionArticle; +} + +/** + * 작성·수정이 같은 폼을 쓴다. 이미지는 글이 있어야 올릴 수 있어서 + * 작성은 생성 → 업로드, 수정은 업로드 → PUT(합친 images) 순으로 간다. + */ +export const usePromotionForm = ({ + clubId, + article, +}: UsePromotionFormParams) => { + const mode = article ? 'edit' : 'create'; + const [values, setValues] = + useState(EMPTY_PROMOTION_FORM); + const [isSaving, setIsSaving] = useState(false); + + const { mutateAsync: createArticle } = useCreatePromotionArticle(); + const { mutateAsync: updateArticle } = useUpdatePromotionArticle(); + const { mutateAsync: uploadImage } = useUploadPromotionImage(); + + // 수정 모드에서 목록 쿼리가 늦게 도착해도 폼에 채워지도록 하되, + // 같은 글의 재조회(업로드 후 invalidate 등)로 입력 중인 값을 덮어쓰지 않도록 id 기준으로 한 번만 채운다. + // 렌더 중 상태 조정 패턴(react.dev "이전 렌더 값 저장")이라 effect 없이 동기화된다. + const [loadedArticleId, setLoadedArticleId] = useState(null); + if (article && article.id !== loadedArticleId) { + setLoadedArticleId(article.id); + setValues(articleToFormValues(article)); + } + + // 미리보기 URL은 화면을 떠날 때 모두 해제한다 (PhotoEditTab의 feedItemsRef와 같은 방식) + const valuesRef = useRef(values); + useEffect(() => { + valuesRef.current = values; + }, [values]); + useEffect( + () => () => + valuesRef.current.localFiles.forEach(({ previewUrl }) => + URL.revokeObjectURL(previewUrl), + ), + [], + ); + + const setField = ( + key: K, + value: PromotionFormValues[K], + ) => setValues((prev) => ({ ...prev, [key]: value })); + + const addLocalFiles = (files: File[]) => + setValues((prev) => ({ + ...prev, + localFiles: [ + ...prev.localFiles, + ...files.map((file) => ({ + file, + previewUrl: URL.createObjectURL(file), + })), + ], + })); + + const removeLocalFile = (index: number) => + setValues((prev) => { + const target = prev.localFiles[index]; + if (target) URL.revokeObjectURL(target.previewUrl); + return { + ...prev, + localFiles: prev.localFiles.filter((_, i) => i !== index), + }; + }); + + const removeExistingImage = (url: string) => + setValues((prev) => ({ + ...prev, + existingImages: prev.existingImages.filter((image) => image !== url), + })); + + const uploadFiles = async (articleId: string) => { + const uploadedUrls: string[] = []; + const uploadedPreviews: string[] = []; + let failedCount = 0; + for (const { file, previewUrl } of values.localFiles) { + try { + const result = await uploadImage({ articleId, file }); + if (result?.imageUrl) { + uploadedUrls.push(result.imageUrl); + uploadedPreviews.push(previewUrl); + } else failedCount += 1; + } catch { + failedCount += 1; + } + } + // 올라간 파일은 서버 이미지로 옮겨 둔다. 일부 실패로 화면에 남았을 때 다시 저장해도 중복 업로드되지 않는다. + setValues((prev) => ({ + ...prev, + existingImages: [...prev.existingImages, ...uploadedUrls], + localFiles: prev.localFiles.filter(({ previewUrl }) => { + const uploaded = uploadedPreviews.includes(previewUrl); + if (uploaded) URL.revokeObjectURL(previewUrl); + return !uploaded; + }), + })); + return { uploadedUrls, failedCount }; + }; + + const save = async (): Promise => { + const validationError = validatePromotionForm(values, mode); + if (validationError) return { status: 'error', message: validationError }; + + setIsSaving(true); + try { + if (mode === 'create') { + const created = await createArticle( + buildPromotionPayload(values, clubId, []), + ); + if (!created?.articleId) { + return { + status: 'error', + message: '홍보 게시글 저장에 실패했습니다.', + }; + } + const { failedCount } = await uploadFiles(created.articleId); + return failedCount > 0 + ? { status: 'partial', articleId: created.articleId, failedCount } + : { status: 'success', articleId: created.articleId }; + } + + const articleId = article!.id; + const { uploadedUrls, failedCount } = await uploadFiles(articleId); + const images = [...values.existingImages, ...uploadedUrls]; + if (images.length === 0) { + return { + status: 'error', + message: '이미지 업로드에 실패했습니다. 다시 시도해주세요.', + }; + } + await updateArticle({ + articleId, + payload: buildPromotionPayload(values, clubId, images), + }); + return failedCount > 0 + ? { status: 'partial', articleId, failedCount } + : { status: 'success', articleId }; + } catch (error) { + return { + status: 'error', + message: getServerErrorMessage( + error, + '홍보 게시글 저장에 실패했습니다.', + ), + }; + } finally { + setIsSaving(false); + } + }; + + return { + mode, + values, + setField, + addLocalFiles, + removeLocalFile, + removeExistingImage, + isSaving, + save, + }; +}; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts new file mode 100644 index 000000000..84d6439f9 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts @@ -0,0 +1,170 @@ +import { PromotionArticle } from '@/types/promotion'; +import { + articleToFormValues, + BUILDING_OPTIONS, + buildPromotionPayload, + EMPTY_PROMOTION_FORM, + findBuildingByCoordinates, + fromDateTimeLocalValue, + PromotionFormValues, + toDateTimeLocalValue, + validatePromotionForm, +} from './promotionForm'; + +const validValues: PromotionFormValues = { + title: '봄 정기공연', + location: '한울관(E31) 302호', + coordinates: { lat: 35.132367, lng: 129.106974 }, + eventStart: new Date('2026-04-01T10:00:00+09:00'), + eventEnd: new Date('2026-04-01T12:00:00+09:00'), + description: '연극 정기공연입니다.', + existingImages: [], + localFiles: [], +}; + +const makeLocalImage = (name: string) => ({ + file: new File(['x'], name, { type: 'image/png' }), + previewUrl: `blob:${name}`, +}); + +describe('BUILDING_OPTIONS', () => { + it('건물명 기준으로 중복 없이 좌표를 갖는다', () => { + const names = BUILDING_OPTIONS.map((o) => o.value); + expect(new Set(names).size).toBe(names.length); + expect(BUILDING_OPTIONS.length).toBeGreaterThan(0); + BUILDING_OPTIONS.forEach((o) => { + expect(typeof o.coordinates.lat).toBe('number'); + expect(typeof o.coordinates.lng).toBe('number'); + }); + }); + + it('좌표로 건물을 되찾을 수 있고 없는 좌표면 undefined', () => { + const first = BUILDING_OPTIONS[0]; + expect(findBuildingByCoordinates(first.coordinates)?.value).toBe( + first.value, + ); + expect(findBuildingByCoordinates({ lat: 0, lng: 0 })).toBeUndefined(); + expect(findBuildingByCoordinates(null)).toBeUndefined(); + }); +}); + +describe('validatePromotionForm', () => { + it('모든 필수값이 있으면 null', () => { + expect(validatePromotionForm(validValues, 'create')).toBeNull(); + }); + + it.each<[keyof PromotionFormValues, unknown, string]>([ + ['title', ' ', '제목을 입력해주세요.'], + ['location', '', '행사 장소를 입력해주세요.'], + ['coordinates', null, '지도에 표시할 건물을 선택해주세요.'], + ['eventStart', null, '행사 기간을 선택해주세요.'], + ['eventEnd', null, '행사 기간을 선택해주세요.'], + ['description', '', '행사 설명을 입력해주세요.'], + ])('%s 가 비면 안내 문구를 돌려준다', (key, value, message) => { + expect( + validatePromotionForm({ ...validValues, [key]: value }, 'create'), + ).toBe(message); + }); + + it('종료가 시작보다 빠르면 막는다', () => { + expect( + validatePromotionForm( + { + ...validValues, + eventEnd: new Date('2026-03-31T10:00:00+09:00'), + }, + 'create', + ), + ).toBe('행사 종료 일시는 시작 일시보다 빠를 수 없습니다.'); + }); + + it('생성은 이미지가 없어도 되지만 수정은 1장 이상이어야 한다', () => { + expect(validatePromotionForm(validValues, 'create')).toBeNull(); + expect(validatePromotionForm(validValues, 'edit')).toBe( + '이미지를 1장 이상 등록해주세요.', + ); + expect( + validatePromotionForm( + { ...validValues, existingImages: ['https://cdn/a.png'] }, + 'edit', + ), + ).toBeNull(); + expect( + validatePromotionForm( + { ...validValues, localFiles: [makeLocalImage('a.png')] }, + 'edit', + ), + ).toBeNull(); + }); +}); + +describe('buildPromotionPayload', () => { + it('트림한 값과 ISO Instant 날짜, 넘겨받은 images로 바디를 만든다', () => { + const payload = buildPromotionPayload( + { ...validValues, title: ' 봄 정기공연 ' }, + 'club-1', + ['https://cdn/a.png'], + ); + expect(payload).toEqual({ + clubId: 'club-1', + title: '봄 정기공연', + location: '한울관(E31) 302호', + latitude: 35.132367, + longitude: 129.106974, + eventStartDate: '2026-04-01T01:00:00.000Z', + eventEndDate: '2026-04-01T03:00:00.000Z', + description: '연극 정기공연입니다.', + images: ['https://cdn/a.png'], + }); + }); + + it('검증 전 값으로 호출하면 던진다', () => { + expect(() => + buildPromotionPayload(EMPTY_PROMOTION_FORM, 'club-1', []), + ).toThrow(); + }); +}); + +describe('articleToFormValues', () => { + const article: PromotionArticle = { + id: 'a1', + clubId: 'club-1', + clubName: '극예술연구회', + title: '봄 정기공연', + location: '한울관(E31) 302호', + latitude: 35.132367, + longitude: 129.106974, + eventStartDate: '2026-04-01T01:00:00Z', + eventEndDate: '2026-04-01T03:00:00Z', + description: '설명', + images: ['https://cdn/a.png'], + }; + + it('서버 글을 폼 값으로 바꾼다', () => { + const values = articleToFormValues(article); + expect(values.coordinates).toEqual({ lat: 35.132367, lng: 129.106974 }); + expect(values.eventStart?.toISOString()).toBe('2026-04-01T01:00:00.000Z'); + expect(values.existingImages).toEqual(['https://cdn/a.png']); + expect(values.localFiles).toEqual([]); + }); + + it('좌표가 없으면 coordinates는 null', () => { + const values = articleToFormValues({ + ...article, + latitude: undefined, + longitude: undefined, + }); + expect(values.coordinates).toBeNull(); + }); +}); + +describe('datetime-local 변환', () => { + it('로컬 시간 문자열과 Date를 왕복한다', () => { + const date = new Date(2026, 3, 1, 9, 5); + const value = toDateTimeLocalValue(date); + expect(value).toBe('2026-04-01T09:05'); + expect(fromDateTimeLocalValue(value)?.getTime()).toBe(date.getTime()); + expect(toDateTimeLocalValue(null)).toBe(''); + expect(fromDateTimeLocalValue('')).toBeNull(); + }); +}); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts new file mode 100644 index 000000000..79fe215d0 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts @@ -0,0 +1,159 @@ +import { + PROMOTION_DESCRIPTION_MAX, + PROMOTION_LOCATION_MAX, + PROMOTION_TITLE_MAX, +} from '@/constants/adminFieldLimits'; +import { clubLocations } from '@/constants/clubLocation'; +import { + CreatePromotionArticleRequest, + PromotionArticle, +} from '@/types/promotion'; + +export interface Coordinates { + lat: number; + lng: number; +} + +/** 아직 올리지 않은 로컬 파일. previewUrl은 createObjectURL 결과라 버릴 때 revoke해야 한다 */ +export interface LocalImage { + file: File; + previewUrl: string; +} + +export interface PromotionFormValues { + title: string; + location: string; + coordinates: Coordinates | null; + eventStart: Date | null; + eventEnd: Date | null; + description: string; + /** 서버에 이미 올라간 이미지 URL (수정 시 삭제 가능) */ + existingImages: string[]; + localFiles: LocalImage[]; +} + +export interface BuildingOption { + label: string; + value: string; + coordinates: Coordinates; +} + +/** + * 관리자가 위도·경도를 직접 입력하지 않도록 캠퍼스 건물 목록에서 고른다. + * 같은 건물이 여러 동아리에 걸쳐 있으니 건물명 기준으로 한 번만 남긴다. + */ +export const BUILDING_OPTIONS: BuildingOption[] = clubLocations.reduce< + BuildingOption[] +>((options, { building, lat, lng }) => { + if (options.some((option) => option.value === building)) return options; + options.push({ label: building, value: building, coordinates: { lat, lng } }); + return options; +}, []); + +export const findBuildingByCoordinates = ( + coordinates: Coordinates | null, +): BuildingOption | undefined => { + if (!coordinates) return undefined; + return BUILDING_OPTIONS.find( + ({ coordinates: c }) => + c.lat === coordinates.lat && c.lng === coordinates.lng, + ); +}; + +export const EMPTY_PROMOTION_FORM: PromotionFormValues = { + title: '', + location: '', + coordinates: null, + eventStart: null, + eventEnd: null, + description: '', + existingImages: [], + localFiles: [], +}; + +const toDateOrNull = (value: string): Date | null => { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +export const articleToFormValues = ( + article: PromotionArticle, +): PromotionFormValues => ({ + title: article.title, + location: article.location, + coordinates: + article.latitude != null && article.longitude != null + ? { lat: article.latitude, lng: article.longitude } + : null, + eventStart: toDateOrNull(article.eventStartDate), + eventEnd: toDateOrNull(article.eventEndDate), + description: article.description, + existingImages: article.images ?? [], + localFiles: [], +}); + +/** + * 저장 전 검증. 문제가 있으면 사용자에게 보여줄 문구를, 없으면 null을 돌려준다. + * 수정은 서버가 images를 1개 이상 요구하므로 mode로 구분한다. + */ +export const validatePromotionForm = ( + values: PromotionFormValues, + mode: 'create' | 'edit', +): string | null => { + if (!values.title.trim()) return '제목을 입력해주세요.'; + if (values.title.trim().length > PROMOTION_TITLE_MAX) + return `제목은 ${PROMOTION_TITLE_MAX}자 이내로 입력해주세요.`; + if (!values.location.trim()) return '행사 장소를 입력해주세요.'; + if (values.location.trim().length > PROMOTION_LOCATION_MAX) + return `행사 장소는 ${PROMOTION_LOCATION_MAX}자 이내로 입력해주세요.`; + if (!values.coordinates) return '지도에 표시할 건물을 선택해주세요.'; + if (!values.eventStart || !values.eventEnd) + return '행사 기간을 선택해주세요.'; + if (values.eventEnd < values.eventStart) + return '행사 종료 일시는 시작 일시보다 빠를 수 없습니다.'; + if (!values.description.trim()) return '행사 설명을 입력해주세요.'; + if (values.description.trim().length > PROMOTION_DESCRIPTION_MAX) + return `행사 설명은 ${PROMOTION_DESCRIPTION_MAX}자 이내로 입력해주세요.`; + if ( + mode === 'edit' && + values.existingImages.length + values.localFiles.length === 0 + ) + return '이미지를 1장 이상 등록해주세요.'; + return null; +}; + +/** + * 검증을 통과한 값으로 요청 바디를 만든다. + * 날짜는 ISO Instant(UTC)로 보낸다. images는 호출부가 업로드 결과를 합쳐 넘긴다. + */ +export const buildPromotionPayload = ( + values: PromotionFormValues, + clubId: string, + images: string[], +): CreatePromotionArticleRequest => { + if (!values.coordinates || !values.eventStart || !values.eventEnd) { + throw new Error('validatePromotionForm을 먼저 통과해야 합니다.'); + } + return { + clubId, + title: values.title.trim(), + location: values.location.trim(), + latitude: values.coordinates.lat, + longitude: values.coordinates.lng, + eventStartDate: values.eventStart.toISOString(), + eventEndDate: values.eventEnd.toISOString(), + description: values.description.trim(), + images, + }; +}; + +/** `` 값(로컬 시간, 분 단위)으로 변환 */ +export const toDateTimeLocalValue = (date: Date | null): string => { + if (!date) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +}; + +export const fromDateTimeLocalValue = (value: string): Date | null => + toDateOrNull(value); diff --git a/frontend/src/types/promotion.ts b/frontend/src/types/promotion.ts index 2dba71905..fe180ec6e 100644 --- a/frontend/src/types/promotion.ts +++ b/frontend/src/types/promotion.ts @@ -12,12 +12,29 @@ export interface PromotionArticle { images: string[]; } +/** + * 생성·수정 공통 바디. 전부 필수. + * clubId는 필수값이지만 동아리 관리자 요청에서는 서버가 토큰의 동아리로 덮어쓴다. + * 생성은 images가 빈 배열이어도 되고, 수정은 1개 이상이어야 한다. + */ export interface CreatePromotionArticleRequest { clubId: string; title: string; location: string; + latitude: number; + longitude: number; eventStartDate: string; eventEndDate: string; description: string; images: string[]; } + +export type UpdatePromotionArticleRequest = CreatePromotionArticleRequest; + +export interface CreatePromotionArticleResponse { + articleId: string; +} + +export interface PromotionImageUploadResponse { + imageUrl: string; +} From b5ff02208116594ead6dd376db068fa4972971a5 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sat, 5 Sep 2026 20:14:51 +0900 Subject: [PATCH 04/28] =?UTF-8?q?feat(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=9E=91=EC=84=B1=20=ED=8F=BC?= =?UTF-8?q?=EC=9D=98=20=ED=96=89=EC=82=AC=20=EA=B8=B0=EA=B0=84=EC=9D=84=20?= =?UTF-8?q?=EC=98=A4=EB=8A=98=EB=A1=9C=20=EA=B8=B0=EB=B3=B8=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모집정보 탭과 같이 날짜가 비어 있으면 오늘로 채운다 - 모듈 상수면 날짜가 고정되므로 초기값을 함수로 만든다 --- .../PromotionTab/hooks/usePromotionForm.ts | 7 +++--- .../PromotionTab/utils/promotionForm.test.ts | 15 +++++++++++-- .../tabs/PromotionTab/utils/promotionForm.ts | 22 +++++++++++-------- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts index 091cb88bf..753b88f66 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts @@ -9,7 +9,7 @@ import { PromotionArticle } from '@/types/promotion'; import { articleToFormValues, buildPromotionPayload, - EMPTY_PROMOTION_FORM, + createEmptyPromotionForm, PromotionFormValues, validatePromotionForm, } from '../utils/promotionForm'; @@ -35,8 +35,9 @@ export const usePromotionForm = ({ article, }: UsePromotionFormParams) => { const mode = article ? 'edit' : 'create'; - const [values, setValues] = - useState(EMPTY_PROMOTION_FORM); + const [values, setValues] = useState( + createEmptyPromotionForm, + ); const [isSaving, setIsSaving] = useState(false); const { mutateAsync: createArticle } = useCreatePromotionArticle(); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts index 84d6439f9..8e721a9f0 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts @@ -3,7 +3,7 @@ import { articleToFormValues, BUILDING_OPTIONS, buildPromotionPayload, - EMPTY_PROMOTION_FORM, + createEmptyPromotionForm, findBuildingByCoordinates, fromDateTimeLocalValue, PromotionFormValues, @@ -120,11 +120,22 @@ describe('buildPromotionPayload', () => { it('검증 전 값으로 호출하면 던진다', () => { expect(() => - buildPromotionPayload(EMPTY_PROMOTION_FORM, 'club-1', []), + buildPromotionPayload(createEmptyPromotionForm(), 'club-1', []), ).toThrow(); }); }); +describe('createEmptyPromotionForm', () => { + it('행사 기간은 오늘로 채워 두고 나머지는 비어 있다', () => { + const values = createEmptyPromotionForm(); + const today = new Date().toDateString(); + expect(values.eventStart?.toDateString()).toBe(today); + expect(values.eventEnd?.toDateString()).toBe(today); + expect(values.title).toBe(''); + expect(values.coordinates).toBeNull(); + }); +}); + describe('articleToFormValues', () => { const article: PromotionArticle = { id: 'a1', diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts index 79fe215d0..a437e45da 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts @@ -60,15 +60,19 @@ export const findBuildingByCoordinates = ( ); }; -export const EMPTY_PROMOTION_FORM: PromotionFormValues = { - title: '', - location: '', - coordinates: null, - eventStart: null, - eventEnd: null, - description: '', - existingImages: [], - localFiles: [], +/** 작성 폼 초기값. 행사 기간은 모집정보 탭과 같이 오늘로 채워 둔다 (모듈 상수로 두면 날짜가 고정돼 함수로 만든다) */ +export const createEmptyPromotionForm = (): PromotionFormValues => { + const today = new Date(); + return { + title: '', + location: '', + coordinates: null, + eventStart: today, + eventEnd: today, + description: '', + existingImages: [], + localFiles: [], + }; }; const toDateOrNull = (value: string): Date | null => { From 6f18023bf9072a74c410220a3615533b6392b3da Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sat, 5 Sep 2026 21:17:35 +0900 Subject: [PATCH 05/28] =?UTF-8?q?feat(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=9E=91=EC=84=B1=20=ED=8F=BC?= =?UTF-8?q?=EC=9D=98=20=EA=B8=B0=EB=B3=B8=20=ED=96=89=EC=82=AC=20=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=EC=9D=84=20=EB=8B=A4=EC=9D=8C=20=EC=A0=95=EC=8B=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AC=EB=A6=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현재 시각 그대로면 14:23 같은 분 단위가 들어가 매번 고쳐야 한다 - 23시대에는 다음 날 0시로 넘어간다 --- .../PromotionTab/utils/promotionForm.test.ts | 18 ++++++++++++++---- .../tabs/PromotionTab/utils/promotionForm.ts | 9 +++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts index 8e721a9f0..bd72bfda0 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts @@ -126,14 +126,24 @@ describe('buildPromotionPayload', () => { }); describe('createEmptyPromotionForm', () => { - it('행사 기간은 오늘로 채워 두고 나머지는 비어 있다', () => { + it('행사 기간은 다음 정시로 채워 두고 나머지는 비어 있다', () => { + jest.useFakeTimers().setSystemTime(new Date(2026, 8, 5, 14, 23, 45)); const values = createEmptyPromotionForm(); - const today = new Date().toDateString(); - expect(values.eventStart?.toDateString()).toBe(today); - expect(values.eventEnd?.toDateString()).toBe(today); + jest.useRealTimers(); + + expect(values.eventStart).toEqual(new Date(2026, 8, 5, 15, 0, 0)); + expect(values.eventEnd).toEqual(new Date(2026, 8, 5, 15, 0, 0)); expect(values.title).toBe(''); expect(values.coordinates).toBeNull(); }); + + it('23시대에는 다음 날 0시로 넘어간다', () => { + jest.useFakeTimers().setSystemTime(new Date(2026, 8, 5, 23, 10)); + const values = createEmptyPromotionForm(); + jest.useRealTimers(); + + expect(values.eventStart).toEqual(new Date(2026, 8, 6, 0, 0, 0)); + }); }); describe('articleToFormValues', () => { diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts index a437e45da..f2d399060 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts @@ -1,3 +1,4 @@ +import { addHours, startOfHour } from 'date-fns'; import { PROMOTION_DESCRIPTION_MAX, PROMOTION_LOCATION_MAX, @@ -60,15 +61,15 @@ export const findBuildingByCoordinates = ( ); }; -/** 작성 폼 초기값. 행사 기간은 모집정보 탭과 같이 오늘로 채워 둔다 (모듈 상수로 두면 날짜가 고정돼 함수로 만든다) */ +/** 작성 폼 초기값. 행사 기간은 오늘의 다음 정시로 채워 둔다 (모듈 상수로 두면 날짜가 고정돼 함수로 만든다) */ export const createEmptyPromotionForm = (): PromotionFormValues => { - const today = new Date(); + const nextHour = startOfHour(addHours(new Date(), 1)); return { title: '', location: '', coordinates: null, - eventStart: today, - eventEnd: today, + eventStart: nextHour, + eventEnd: nextHour, description: '', existingImages: [], localFiles: [], From 7a7daa06cbf22f0a3c3d57f6bafed62c38587a87 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 6 Sep 2026 01:48:42 +0900 Subject: [PATCH 06/28] =?UTF-8?q?refactor(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=8B=AC=EC=82=AC=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20enum=20=EC=9D=B4=EB=A6=84=20AVAILABLE=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A1=9C=20=EC=A0=95=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드 #2013에서 상세 응답 state를 목록과 같은 enum 이름으로 통일했다 - 설명값('활성화') 허용 분기는 더 이상 올 수 없는 값이라 제거한다 --- frontend/src/hooks/Queries/CLAUDE.md | 2 +- .../AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx | 5 ++--- .../src/pages/AdminPage/tabs/PromotionTab/constants.ts | 8 ++------ 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/frontend/src/hooks/Queries/CLAUDE.md b/frontend/src/hooks/Queries/CLAUDE.md index bcd93b1d0..0ad3a0124 100644 --- a/frontend/src/hooks/Queries/CLAUDE.md +++ b/frontend/src/hooks/Queries/CLAUDE.md @@ -59,4 +59,4 @@ Google OAuth 동의 화면이 테스트 모드면 refresh token이 7일 뒤 만 - 이미지는 글이 있어야 올릴 수 있다(`POST /api/promotion/{id}/upload`). 서버가 업로드된 URL을 글의 `images`에 `$addToSet`으로 바로 넣으므로 **작성은 생성 → 업로드로 끝**, **수정은 업로드 → PUT(기존 유지분 + 새 URL)** 순서다. 순서를 뒤집으면 PUT이 새 URL을 모르거나 삭제한 이미지를 서버가 다시 살린다 - 수정 PUT은 `images`가 1개 이상이어야 한다(`@NotEmpty`). 생성은 빈 배열 허용 -- 심사 전 동아리는 서버가 403(902-2)로 막는다. 화면은 요청 전에 `ClubDetail.state`로 먼저 막고 같은 문구를 보여준다. 주의: 상세 API의 `state`는 enum 이름이 아니라 설명값(`'활성화'`/`'비활성화'`)이고 목록 API는 `'AVAILABLE'`이다. 판정은 `PromotionTab/constants.ts`의 `isClubApproved`로만 한다 +- 심사 전 동아리는 서버가 403(902-2)로 막는다. 화면은 요청 전에 `ClubDetail.state === 'AVAILABLE'`로 먼저 막고 같은 문구를 보여준다. 판정은 `PromotionTab/constants.ts`의 `isClubApproved`로만 한다. 상세 API가 한때 설명값(`'활성화'`)을 줬는데 백엔드 #2013에서 enum 이름으로 통일됐다 diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx index d4ee02740..99fa1453c 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx @@ -41,8 +41,7 @@ const makeArticle = ( ...overrides, }); -// 상세 API는 state를 설명값('활성화'/'비활성화')으로 준다 -const renderTab = (state = '활성화') => { +const renderTab = (state = 'AVAILABLE') => { render( @@ -82,7 +81,7 @@ describe('PromotionListTab', () => { }); it('심사 전 동아리는 작성 버튼 대신 안내 문구를 보여준다', () => { - renderTab('비활성화'); + renderTab('UNAVAILABLE'); expect( screen.queryByRole('button', { name: /새 게시글 작성/ }), diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/constants.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/constants.ts index 41a61996a..98d214c03 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/constants.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/constants.ts @@ -4,10 +4,6 @@ export const PROMOTION_LIST_PATH = '/admin/promotion'; export const PROMOTION_NOT_APPROVED_MESSAGE = '심사가 완료된 동아리만 홍보 게시글을 작성할 수 있습니다.'; -/** - * 심사 완료 여부. 상세 API(`GET /api/club/{id}`)는 state를 enum 이름이 아니라 - * 설명값('활성화'/'비활성화')으로 내려주고, 목록 API는 'AVAILABLE'을 준다. - * 둘 다 받아 백엔드가 나중에 이름으로 통일해도 깨지지 않게 한다. - */ +/** 심사 완료 여부. 상세·목록 API 모두 ClubState enum 이름('AVAILABLE'/'UNAVAILABLE')을 준다 (백엔드 #2013에서 통일) */ export const isClubApproved = (state: string | undefined) => - state === '활성화' || state === 'AVAILABLE'; + state === 'AVAILABLE'; From ba24a3a5f1ae3d43a34e16bafb222a6270f561c2 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 6 Sep 2026 02:26:40 +0900 Subject: [PATCH 07/28] =?UTF-8?q?refactor(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20presigned=20URL=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/promotion/{id}/upload-url로 발급받아 R2에 raw fetch로 PUT하고 finalUrl을 PUT images로 반영한다 - 발급 API가 게시글을 건드리지 않아 작성·수정 모두 (생성) → 업로드 → PUT 한 흐름으로 합친다 - multipart 호출부를 제거하고 항목별 success를 따로 다룬다 --- frontend/src/apis/CLAUDE.md | 2 +- frontend/src/apis/promotion.test.ts | 118 ++++++++++++++---- frontend/src/apis/promotion.ts | 46 +++++-- frontend/src/hooks/Queries/CLAUDE.md | 2 +- frontend/src/hooks/Queries/usePromotion.ts | 70 +++++++++-- .../PromotionTab/hooks/usePromotionForm.ts | 63 +++++----- frontend/src/types/promotion.ts | 18 ++- 7 files changed, 234 insertions(+), 85 deletions(-) diff --git a/frontend/src/apis/CLAUDE.md b/frontend/src/apis/CLAUDE.md index 7254754e3..1be787286 100644 --- a/frontend/src/apis/CLAUDE.md +++ b/frontend/src/apis/CLAUDE.md @@ -18,7 +18,7 @@ API는 `src/apis/utils/apiHelpers.ts`의 헬퍼 함수를 사용하는 일관된 - JWT는 localStorage에 저장 (`accessToken` 키, `src/constants/storageKeys.ts`에서 관리) - 리프레시 토큰은 쿠키로 처리 (`credentials: 'include'`) - `secureFetch()`가 1차 요청 → 401이면 `refreshAccessToken()`으로 토큰 재발급 후 재요청. refresh 실패 시 `REFRESH_FAILED` 에러 -- 재요청도 호출부가 넘긴 headers를 그대로 쓴다. `Content-Type`을 강제로 붙이지 않는 이유는 홍보 이미지 업로드(`uploadPromotionImage`)가 FormData라 브라우저가 boundary를 붙여야 하기 때문. JSON 호출부는 전부 직접 `Content-Type: application/json`을 넘긴다 +- 재요청도 호출부가 넘긴 headers를 그대로 쓴다. 예전엔 재요청에만 `Content-Type: application/json`을 강제해서 FormData(multipart)를 보내면 브라우저가 붙인 boundary가 덮여 재요청이 깨졌다. 지금은 multipart 호출부가 없지만(홍보 이미지도 presigned로 올린다) 재시도 경로는 1차와 같은 요청이어야 하므로 유지한다. JSON 호출부는 전부 직접 `Content-Type: application/json`을 넘긴다 - 어드민 라우트는 `PrivateRoute` 컴포넌트로 보호 ### 익명 학생 토큰 (우체통) diff --git a/frontend/src/apis/promotion.test.ts b/frontend/src/apis/promotion.test.ts index b9c4f2c49..c6205d18f 100644 --- a/frontend/src/apis/promotion.test.ts +++ b/frontend/src/apis/promotion.test.ts @@ -2,13 +2,15 @@ import fetchMock from 'jest-fetch-mock'; import { CreatePromotionArticleRequest, PromotionArticle, + PromotionPresignedData, } from '@/types/promotion'; import { createPromotionArticle, deletePromotionArticle, getPromotionArticles, + getPromotionImageUploadUrls, updatePromotionArticle, - uploadPromotionImage, + uploadPromotionImageToStorage, } from './promotion'; jest.mock('@/constants/api', () => ({ @@ -235,39 +237,103 @@ describe('promotion API', () => { }); }); - describe('uploadPromotionImage', () => { - it('multipart의 file 필드로 올리고 imageUrl을 돌려준다', async () => { - fetchMock.mockResponseOnce( - JSON.stringify({ data: { imageUrl: 'https://cdn/a.png' } }), - { headers: { 'content-type': 'application/json' } }, + describe('getPromotionImageUploadUrls', () => { + it('파일 목록을 배열로 보내고 항목별 발급 결과를 돌려준다', async () => { + const presigned: PromotionPresignedData[] = [ + { + presignedUrl: 'https://r2/put?sig=1', + finalUrl: 'https://cdn/promotion/articles/123/2026/09/a.png', + requiredHeaders: { 'Content-Type': 'image/png' }, + success: true, + failureReason: null, + }, + { + presignedUrl: null, + finalUrl: '', + requiredHeaders: {}, + success: false, + failureReason: '허용되지 않는 형식', + }, + ]; + fetchMock.mockResponseOnce(JSON.stringify({ data: presigned }), { + headers: { 'content-type': 'application/json' }, + }); + const requests = [ + { fileName: 'a.png', contentType: 'image/png' }, + { fileName: 'b.svg', contentType: 'image/svg+xml' }, + ]; + + const result = await getPromotionImageUploadUrls('123', requests); + + // 한 항목이 실패해도 배열 전체를 실패로 보지 않는다 + expect(result).toEqual(presigned); + expect(fetchMock).toHaveBeenCalledWith( + `${API_BASE_URL}/api/promotion/123/upload-url`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(requests), + }), ); - const file = new File(['x'], 'a.png', { type: 'image/png' }); + }); - const result = await uploadPromotionImage('123', file); + it('남의 동아리 글이면 에러를 던진다', async () => { + fetchMock.mockResponseOnce(JSON.stringify({ message: '권한 없음' }), { + status: 403, + }); - expect(result).toEqual({ imageUrl: 'https://cdn/a.png' }); + await expect( + getPromotionImageUploadUrls('123', [ + { fileName: 'a.png', contentType: 'image/png' }, + ]), + ).rejects.toThrow('홍보 이미지 업로드 URL 생성에 실패했습니다.'); + }); + }); + + describe('uploadPromotionImageToStorage', () => { + const file = new File(['x'], 'a.png', { type: 'image/png' }); + const presigned: PromotionPresignedData = { + presignedUrl: 'https://r2/put?sig=1', + finalUrl: 'https://cdn/a.png', + requiredHeaders: { 'Content-Type': 'image/png' }, + success: true, + failureReason: null, + }; + + it('presigned URL에 requiredHeaders만 실어 PUT하고 Authorization은 붙이지 않는다', async () => { + fetchMock.mockResponseOnce('', { status: 200 }); + + await expect( + uploadPromotionImageToStorage(presigned, file), + ).resolves.toBeUndefined(); const [url, options] = fetchMock.mock.calls[0]; - expect(url).toBe(`${API_BASE_URL}/api/promotion/123/upload`); - expect(options?.method).toBe('POST'); - expect(options?.body).toBeInstanceOf(FormData); - // jest-fetch-mock이 FormData 값을 문자열로 바꿔 두므로 필드 존재만 확인한다 - expect((options?.body as FormData).has('file')).toBe(true); - // multipart boundary는 브라우저가 붙여야 하므로 Content-Type을 직접 넣지 않는다 - expect( - (options?.headers as Record)['Content-Type'], - ).toBeUndefined(); + expect(url).toBe('https://r2/put?sig=1'); + expect(options?.method).toBe('PUT'); + expect(options?.body).toBe(file); + expect(options?.headers).toEqual({ 'Content-Type': 'image/png' }); }); - it('실패 시 에러를 던진다', async () => { - fetchMock.mockResponseOnce(JSON.stringify({ message: '실패' }), { - status: 500, - }); - const file = new File(['x'], 'a.png', { type: 'image/png' }); + it('발급이 실패한 항목은 요청 없이 사유로 던진다', async () => { + await expect( + uploadPromotionImageToStorage( + { + ...presigned, + presignedUrl: null, + success: false, + failureReason: '허용되지 않는 형식', + }, + file, + ), + ).rejects.toThrow('허용되지 않는 형식'); + expect(fetchMock).not.toHaveBeenCalled(); + }); - await expect(uploadPromotionImage('123', file)).rejects.toThrow( - '홍보 이미지 업로드에 실패했습니다.', - ); + it('스토리지가 거부하면 에러를 던진다', async () => { + fetchMock.mockResponseOnce('', { status: 403 }); + + await expect( + uploadPromotionImageToStorage(presigned, file), + ).rejects.toThrow('스토리지 업로드 실패 : 403'); }); }); }); diff --git a/frontend/src/apis/promotion.ts b/frontend/src/apis/promotion.ts index 158bcae9f..ef369b3a8 100644 --- a/frontend/src/apis/promotion.ts +++ b/frontend/src/apis/promotion.ts @@ -5,7 +5,8 @@ import { CreatePromotionArticleRequest, CreatePromotionArticleResponse, PromotionArticle, - PromotionImageUploadResponse, + PromotionImageUploadRequest, + PromotionPresignedData, UpdatePromotionArticleRequest, } from '@/types/promotion'; import { secureFetch } from './auth/secureFetch'; @@ -78,23 +79,46 @@ export const deletePromotionArticle = async (articleId: string) => { }; /** - * 이미지는 글을 먼저 만들어 articleId를 받은 뒤 올린다. - * 서버가 업로드된 URL을 해당 글의 images에 바로 추가하므로 생성 직후에는 PUT이 필요 없다. + * 이미지는 글을 먼저 만들어 articleId를 받은 뒤 presigned URL을 발급받아 R2에 직접 올린다. + * 발급 API는 게시글을 건드리지 않으므로 올린 finalUrl은 PUT의 images로 반영해야 한다. + * 항목별로 success가 갈릴 수 있어 배열 전체를 실패로 보지 않는다. */ -export const uploadPromotionImage = async (articleId: string, file: File) => { - const formData = new FormData(); - formData.append('file', file); - +export const getPromotionImageUploadUrls = async ( + articleId: string, + requests: PromotionImageUploadRequest[], +) => { const response = await secureFetch( - `${API_BASE_URL}/api/promotion/${articleId}/upload`, + `${API_BASE_URL}/api/promotion/${articleId}/upload-url`, { method: 'POST', - body: formData, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requests), }, ); - return handleResponse( + return handleResponse( response, - '홍보 이미지 업로드에 실패했습니다.', + '홍보 이미지 업로드 URL 생성에 실패했습니다.', ); }; + +/** + * R2로 직접 나가는 요청이라 secureFetch를 쓰지 않는다. Authorization이 붙으면 서명 검증에 걸린다. + * requiredHeaders는 서명에 포함된 값이라 빠짐없이 그대로 보낸다. + */ +export const uploadPromotionImageToStorage = async ( + presigned: PromotionPresignedData, + file: File, +) => { + if (!presigned.presignedUrl) { + throw new Error(presigned.failureReason ?? 'presigned URL 생성 실패'); + } + const response = await fetch(presigned.presignedUrl, { + method: 'PUT', + body: file, + headers: presigned.requiredHeaders, + }); + await handleResponse(response, `스토리지 업로드 실패 : ${response.status}`); +}; diff --git a/frontend/src/hooks/Queries/CLAUDE.md b/frontend/src/hooks/Queries/CLAUDE.md index 0ad3a0124..8e914d1db 100644 --- a/frontend/src/hooks/Queries/CLAUDE.md +++ b/frontend/src/hooks/Queries/CLAUDE.md @@ -57,6 +57,6 @@ Google OAuth 동의 화면이 테스트 모드면 refresh token이 7일 뒤 만 관리자 CRUD는 목록 쿼리 하나(`queryKeys.promotion.list()`)만 쓰고 생성·수정·삭제·업로드 뮤테이션이 모두 그 키를 무효화한다. 상세 조회 API가 없어 수정 화면도 목록에서 `id`로 찾는다. -- 이미지는 글이 있어야 올릴 수 있다(`POST /api/promotion/{id}/upload`). 서버가 업로드된 URL을 글의 `images`에 `$addToSet`으로 바로 넣으므로 **작성은 생성 → 업로드로 끝**, **수정은 업로드 → PUT(기존 유지분 + 새 URL)** 순서다. 순서를 뒤집으면 PUT이 새 URL을 모르거나 삭제한 이미지를 서버가 다시 살린다 +- 이미지는 피드·로고와 같은 presigned 방식이다. `POST /api/promotion/{id}/upload-url`(배열 요청, 항목별 `success`)로 발급받아 R2에 raw `fetch`로 PUT(`requiredHeaders` 그대로, Authorization 금지)하고, `finalUrl`을 **`PUT /api/promotion/{id}`의 `images`에 전체 목록으로** 보낸다. 발급 API는 게시글을 건드리지 않아 PUT이 이미지 저장의 유일한 경로다. 작성·수정 모두 (작성이면 생성) → 업로드 → PUT 순서(`useUploadPromotionImages`, `usePromotionForm`) - 수정 PUT은 `images`가 1개 이상이어야 한다(`@NotEmpty`). 생성은 빈 배열 허용 - 심사 전 동아리는 서버가 403(902-2)로 막는다. 화면은 요청 전에 `ClubDetail.state === 'AVAILABLE'`로 먼저 막고 같은 문구를 보여준다. 판정은 `PromotionTab/constants.ts`의 `isClubApproved`로만 한다. 상세 API가 한때 설명값(`'활성화'`)을 줬는데 백엔드 #2013에서 enum 이름으로 통일됐다 diff --git a/frontend/src/hooks/Queries/usePromotion.ts b/frontend/src/hooks/Queries/usePromotion.ts index e8ce2fa37..b32138acc 100644 --- a/frontend/src/hooks/Queries/usePromotion.ts +++ b/frontend/src/hooks/Queries/usePromotion.ts @@ -4,10 +4,12 @@ import { createPromotionArticle, deletePromotionArticle, getPromotionArticles, + getPromotionImageUploadUrls, updatePromotionArticle, - uploadPromotionImage, + uploadPromotionImageToStorage, } from '@/apis/promotion'; import { queryKeys } from '@/constants/queryKeys'; +import { ALLOWED_IMAGE_TYPES } from '@/constants/uploadLimit'; import { CreatePromotionArticleRequest, PromotionArticle, @@ -83,20 +85,64 @@ export const useDeletePromotionArticle = () => { }); }; -/** 서버가 업로드된 URL을 글의 images에 바로 추가하므로 목록도 함께 무효화한다 */ -export const useUploadPromotionImage = () => { - const queryClient = useQueryClient(); +interface PromotionImageUploadParams { + articleId: string; + files: File[]; +} - return useMutation({ - mutationFn: ({ articleId, file }: { articleId: string; file: File }) => - uploadPromotionImage(articleId, file), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: queryKeys.promotion.list(), +export interface PromotionImageUploadResult { + /** 올라간 파일과 최종 URL. 요청 순서를 유지한다 */ + uploaded: { file: File; url: string }[]; + failedFiles: File[]; +} + +/** + * presigned URL 발급 → R2 병렬 PUT → 성공한 finalUrl 수집 (useUploadFeed와 같은 흐름). + * 발급 API는 게시글을 건드리지 않으므로 목록 무효화는 PUT 쪽(useUpdatePromotionArticle)에서 한다. + */ +export const useUploadPromotionImages = () => + useMutation({ + mutationFn: async ({ + articleId, + files, + }: PromotionImageUploadParams): Promise => { + if (files.length === 0) return { uploaded: [], failedFiles: [] }; + + const requests = files.map((file) => ({ + fileName: file.name, + contentType: (ALLOWED_IMAGE_TYPES as readonly string[]).includes( + file.type, + ) + ? file.type + : 'image/jpeg', + })); + const presignedList = await getPromotionImageUploadUrls( + articleId, + requests, + ); + if (!presignedList) { + throw new Error('홍보 이미지 업로드 URL 생성 실패'); + } + + // 발급 자체가 실패한 항목(success=false)은 PUT을 건너뛰고 실패로 센다 + const results = await Promise.allSettled( + files.map((file, i) => + uploadPromotionImageToStorage(presignedList[i], file), + ), + ); + + const uploaded: PromotionImageUploadResult['uploaded'] = []; + const failedFiles: File[] = []; + results.forEach((result, i) => { + if (result.status === 'fulfilled' && presignedList[i].finalUrl) { + uploaded.push({ file: files[i], url: presignedList[i].finalUrl }); + } else { + failedFiles.push(files[i]); + } }); + return { uploaded, failedFiles }; }, onError: (error) => { - console.error('Error uploading promotion image:', error); + console.error('Error uploading promotion images:', error); }, }); -}; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts index 753b88f66..08fcdab5e 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts @@ -3,7 +3,7 @@ import { getServerErrorMessage } from '@/apis/utils/getServerErrorMessage'; import { useCreatePromotionArticle, useUpdatePromotionArticle, - useUploadPromotionImage, + useUploadPromotionImages, } from '@/hooks/Queries/usePromotion'; import { PromotionArticle } from '@/types/promotion'; import { @@ -28,7 +28,8 @@ interface UsePromotionFormParams { /** * 작성·수정이 같은 폼을 쓴다. 이미지는 글이 있어야 올릴 수 있어서 - * 작성은 생성 → 업로드, 수정은 업로드 → PUT(합친 images) 순으로 간다. + * (작성이면 먼저 생성) → presigned 업로드 → PUT(기존 유지분 + 새 URL) 순으로 간다. + * PUT이 이미지 저장의 유일한 경로다. */ export const usePromotionForm = ({ clubId, @@ -42,7 +43,7 @@ export const usePromotionForm = ({ const { mutateAsync: createArticle } = useCreatePromotionArticle(); const { mutateAsync: updateArticle } = useUpdatePromotionArticle(); - const { mutateAsync: uploadImage } = useUploadPromotionImage(); + const { mutateAsync: uploadImages } = useUploadPromotionImages(); // 수정 모드에서 목록 쿼리가 늦게 도착해도 폼에 채워지도록 하되, // 같은 글의 재조회(업로드 후 invalidate 등)로 입력 중인 값을 덮어쓰지 않도록 id 기준으로 한 번만 채운다. @@ -100,31 +101,23 @@ export const usePromotionForm = ({ })); const uploadFiles = async (articleId: string) => { - const uploadedUrls: string[] = []; - const uploadedPreviews: string[] = []; - let failedCount = 0; - for (const { file, previewUrl } of values.localFiles) { - try { - const result = await uploadImage({ articleId, file }); - if (result?.imageUrl) { - uploadedUrls.push(result.imageUrl); - uploadedPreviews.push(previewUrl); - } else failedCount += 1; - } catch { - failedCount += 1; - } - } + const { uploaded, failedFiles } = await uploadImages({ + articleId, + files: values.localFiles.map(({ file }) => file), + }); + const uploadedUrls = uploaded.map(({ url }) => url); + const uploadedFiles = new Set(uploaded.map(({ file }) => file)); // 올라간 파일은 서버 이미지로 옮겨 둔다. 일부 실패로 화면에 남았을 때 다시 저장해도 중복 업로드되지 않는다. setValues((prev) => ({ ...prev, existingImages: [...prev.existingImages, ...uploadedUrls], - localFiles: prev.localFiles.filter(({ previewUrl }) => { - const uploaded = uploadedPreviews.includes(previewUrl); - if (uploaded) URL.revokeObjectURL(previewUrl); - return !uploaded; + localFiles: prev.localFiles.filter(({ file, previewUrl }) => { + const isUploaded = uploadedFiles.has(file); + if (isUploaded) URL.revokeObjectURL(previewUrl); + return !isUploaded; }), })); - return { uploadedUrls, failedCount }; + return { uploadedUrls, failedCount: failedFiles.length }; }; const save = async (): Promise => { @@ -133,7 +126,8 @@ export const usePromotionForm = ({ setIsSaving(true); try { - if (mode === 'create') { + let articleId = article?.id; + if (!articleId) { const created = await createArticle( buildPromotionPayload(values, clubId, []), ); @@ -143,21 +137,26 @@ export const usePromotionForm = ({ message: '홍보 게시글 저장에 실패했습니다.', }; } - const { failedCount } = await uploadFiles(created.articleId); - return failedCount > 0 - ? { status: 'partial', articleId: created.articleId, failedCount } - : { status: 'success', articleId: created.articleId }; + articleId = created.articleId; } - const articleId = article!.id; const { uploadedUrls, failedCount } = await uploadFiles(articleId); const images = [...values.existingImages, ...uploadedUrls]; + + // PUT은 images를 1개 이상 요구한다. 작성에서 올릴 이미지가 없으면 PUT할 것도 없고, + // 수정에서 여기 오는 건 검증을 통과한 이미지가 전부 업로드 실패한 경우뿐이다. if (images.length === 0) { - return { - status: 'error', - message: '이미지 업로드에 실패했습니다. 다시 시도해주세요.', - }; + if (mode === 'edit') { + return { + status: 'error', + message: '이미지 업로드에 실패했습니다. 다시 시도해주세요.', + }; + } + return failedCount > 0 + ? { status: 'partial', articleId, failedCount } + : { status: 'success', articleId }; } + await updateArticle({ articleId, payload: buildPromotionPayload(values, clubId, images), diff --git a/frontend/src/types/promotion.ts b/frontend/src/types/promotion.ts index fe180ec6e..0bf10dc98 100644 --- a/frontend/src/types/promotion.ts +++ b/frontend/src/types/promotion.ts @@ -35,6 +35,20 @@ export interface CreatePromotionArticleResponse { articleId: string; } -export interface PromotionImageUploadResponse { - imageUrl: string; +export interface PromotionImageUploadRequest { + fileName: string; + contentType: string; +} + +/** + * presigned URL 발급 결과. 요청 배열과 순서가 1:1로 대응한다. + * 확장자·contentType이 허용 목록 밖이면 그 항목만 success=false, presignedUrl=null로 온다. + */ +export interface PromotionPresignedData { + presignedUrl: string | null; + finalUrl: string; + /** 서명에 포함된 헤더. 스토리지 PUT에 그대로 실어야 한다 */ + requiredHeaders: Record; + success: boolean; + failureReason: string | null; } From 274d2bc88286fec3ee975bd81cfe412add93251e Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 6 Sep 2026 15:26:39 +0900 Subject: [PATCH 08/28] =?UTF-8?q?feat(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=EC=9D=84=2015=EC=9E=A5=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/adminFieldLimits.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/constants/adminFieldLimits.ts b/frontend/src/constants/adminFieldLimits.ts index 3a9fb68f7..24038ff12 100644 --- a/frontend/src/constants/adminFieldLimits.ts +++ b/frontend/src/constants/adminFieldLimits.ts @@ -21,4 +21,4 @@ export const PASSWORD_MAX = 20; export const PROMOTION_TITLE_MAX = 50; export const PROMOTION_LOCATION_MAX = 50; export const PROMOTION_DESCRIPTION_MAX = 1000; -export const PROMOTION_IMAGE_MAX_COUNT = 10; +export const PROMOTION_IMAGE_MAX_COUNT = 15; From e86de4ce226d6f5f2a641b841e79796c46b696a6 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 6 Sep 2026 16:19:53 +0900 Subject: [PATCH 09/28] =?UTF-8?q?test(admin):=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9E=90=EB=A6=AC=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=9B=90=EC=9D=B4=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=A0=A4=EC=A7=80=EB=8A=94=EC=A7=80=20=EB=8B=A8?= =?UTF-8?q?=EC=96=B8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 테스트는 img 부재만 확인해 자리표시자가 사라져도 통과했다 - 장식 요소라 접근성 속성 대신 data-testid로 존재를 확인한다 --- .../components/common/Header/admin/AdminProfile.test.tsx | 7 ++++++- .../src/components/common/Header/admin/AdminProfile.tsx | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/common/Header/admin/AdminProfile.test.tsx b/frontend/src/components/common/Header/admin/AdminProfile.test.tsx index 7ba963dbd..bdbe9e322 100644 --- a/frontend/src/components/common/Header/admin/AdminProfile.test.tsx +++ b/frontend/src/components/common/Header/admin/AdminProfile.test.tsx @@ -17,9 +17,10 @@ describe('AdminProfile', () => { render(); expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByTestId('admin-profile-placeholder')).toBeInTheDocument(); }); - it('로고가 있으면 이미지를 그린다', () => { + it('로고가 있으면 이미지를 그리고 자리표시 원은 없다', () => { mockLogo = 'https://cdn/logo.png'; render(); @@ -27,6 +28,9 @@ describe('AdminProfile', () => { 'src', 'https://cdn/logo.png', ); + expect( + screen.queryByTestId('admin-profile-placeholder'), + ).not.toBeInTheDocument(); }); it('로고를 불러오지 못하면 alt 문구 대신 자리표시 원으로 바꾼다', () => { @@ -36,5 +40,6 @@ describe('AdminProfile', () => { fireEvent.error(screen.getByRole('img')); expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByTestId('admin-profile-placeholder')).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/common/Header/admin/AdminProfile.tsx b/frontend/src/components/common/Header/admin/AdminProfile.tsx index 1a9e3e3e3..a7086585b 100644 --- a/frontend/src/components/common/Header/admin/AdminProfile.tsx +++ b/frontend/src/components/common/Header/admin/AdminProfile.tsx @@ -23,7 +23,10 @@ const AdminProfile = () => { onError={() => setBrokenLogo(logo ?? null)} /> ) : ( - + )} ); From e8c34909955f022621818e363003da0866d4176d Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 6 Sep 2026 16:23:17 +0900 Subject: [PATCH 10/28] =?UTF-8?q?fix(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=88=98=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=90=9C=20=EA=B8=80=EA=B3=BC=20=EA=B5=AC=EB=B6=84=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 초기 조회 실패 시 isError=true, isLoading=false라 article이 undefined가 되어 '삭제됐거나 우리 동아리의 글이 아니에요'로 표시됐다 - 기존 지원서 수정 탭과 같이 isLoading 다음에 isError를 먼저 분기한다 --- .../tabs/PromotionTab/PromotionEditTab.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx index f6091143c..065f0f0d3 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx @@ -50,7 +50,12 @@ const PromotionEditTab = () => { useTrackPageView(PAGE_VIEW.ADMIN_PROMOTION_EDIT_PAGE); - const { data: articles, isLoading } = useGetPromotionArticles(); + const { + data: articles, + isLoading, + isError, + error, + } = useGetPromotionArticles(); const article = articleId ? articles?.find( (item) => item.id === articleId && item.clubId === clubDetail.id, @@ -126,6 +131,20 @@ const PromotionEditTab = () => { if (isEdit && isLoading) return ; + // 목록 조회 자체가 실패한 것과 글이 없는 것을 구분한다. 실패를 "삭제됨"으로 보여주면 사용자가 잘못된 판단을 한다 + if (isEdit && isError) { + return ( + + {isCompact && goToList()} />} + + 게시글을 불러오지 못했어요 + {error.message} + + + + ); + } + if (isEdit && !article) { return ( From 087083449414670f113ab9405176a8272fbb44c6 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 6 Sep 2026 16:34:50 +0900 Subject: [PATCH 11/28] =?UTF-8?q?fix(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=84=A0=ED=83=9D=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=EC=97=90=20=EC=A7=80=EC=9B=90=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=ED=98=95=EC=8B=9D=EC=9D=84=20=EA=B1=B0?= =?UTF-8?q?=EB=B6=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - accept는 선택창 필터일 뿐이라 '모든 파일'로 우회되면 저장 시점에야 실패를 알았다 - 크기 검사와 같은 자리에서 ALLOWED_IMAGE_TYPES 밖 파일을 거부한다 --- .../PromotionImageField.test.tsx | 66 +++++++++++++++++++ .../PromotionImageField.tsx | 11 ++++ 2 files changed, 77 insertions(+) create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.test.tsx diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.test.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.test.tsx new file mode 100644 index 000000000..9abdc790e --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.test.tsx @@ -0,0 +1,66 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import PromotionImageField from './PromotionImageField'; + +const renderField = () => { + const onAddFiles = jest.fn(); + const onReject = jest.fn(); + const { container } = render( + , + ); + const input = container.querySelector( + 'input[type="file"]', + ) as HTMLInputElement; + return { input, onAddFiles, onReject }; +}; + +const selectFiles = (input: HTMLInputElement, files: File[]) => + fireEvent.change(input, { target: { files } }); + +describe('PromotionImageField 파일 선택 검증', () => { + it('이미지 형식이 아니면 선택 시점에 거부하고 추가하지 않는다', () => { + const { input, onAddFiles, onReject } = renderField(); + + selectFiles(input, [ + new File(['x'], 'poster.png', { type: 'image/png' }), + new File(['x'], 'plan.pdf', { type: 'application/pdf' }), + ]); + + expect(onReject).toHaveBeenCalledWith(expect.stringContaining('plan.pdf')); + expect(onAddFiles).not.toHaveBeenCalled(); + }); + + it('허용 형식이면 그대로 추가한다', () => { + const { input, onAddFiles, onReject } = renderField(); + const file = new File(['x'], 'poster.webp', { type: 'image/webp' }); + + selectFiles(input, [file]); + + expect(onReject).not.toHaveBeenCalled(); + expect(onAddFiles).toHaveBeenCalledWith([file]); + }); + + it('10MB를 넘으면 거부한다', () => { + const { input, onAddFiles, onReject } = renderField(); + const big = new File(['x'], 'big.png', { type: 'image/png' }); + Object.defineProperty(big, 'size', { value: 10 * 1024 * 1024 + 1 }); + + selectFiles(input, [big]); + + expect(onReject).toHaveBeenCalledWith(expect.stringContaining('big.png')); + expect(onAddFiles).not.toHaveBeenCalled(); + }); +}); + +// 자리표시 문구·카운터는 시각 요소라 렌더 여부만 확인한다 +it('현재 장수와 상한을 보여준다', () => { + renderField(); + expect(screen.getByText('0/15')).toBeInTheDocument(); +}); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.tsx index d7db1a0a4..70ea5db7d 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.tsx @@ -35,6 +35,17 @@ const PromotionImageField = ({ e.target.value = ''; if (selected.length === 0) return; + // accept는 선택창 필터일 뿐이라 "모든 파일"로 바꾸면 우회된다. 저장 시점에야 실패를 알지 않도록 여기서 막는다 + const unsupported = selected.find( + (file) => !(ALLOWED_IMAGE_TYPES as readonly string[]).includes(file.type), + ); + if (unsupported) { + onReject( + `${unsupported.name}은(는) 지원하지 않는 형식입니다. JPG·PNG·GIF·BMP·WebP만 올릴 수 있어요.`, + ); + return; + } + const oversized = selected.find((file) => file.size > MAX_FILE_SIZE); if (oversized) { onReject(`${oversized.name}의 용량이 10MB를 초과했습니다.`); From bfc76d52ff6a1ed6ea6c66744e77d753f70fe56f Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 6 Sep 2026 16:43:34 +0900 Subject: [PATCH 12/28] =?UTF-8?q?fix(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=ED=8F=BC=EC=9D=B4=20=EC=9E=A0?= =?UTF-8?q?=EA=B8=B0=EB=A9=B4=20=EB=8D=B0=EC=8A=A4=ED=81=AC=ED=86=B1=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=20=ED=94=BC=EC=BB=A4=EB=8F=84=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EC=9E=A0=EA=B7=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DateTimeRangePicker에 disabled prop을 추가해 두 입력을 막고 열린 패널을 숨긴다 - 다른 필드는 비활성화되는데 날짜만 편집되던 불일치를 없앤다. 기본값 false라 모집정보 탭은 영향 없음 --- .../tabs/PromotionTab/PromotionEditTab.tsx | 1 + .../DateTimeRangePicker.tsx | 25 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx index 065f0f0d3..27b3eb1e2 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx @@ -249,6 +249,7 @@ const PromotionEditTab = () => { recruitmentEnd={values.eventEnd} onChangeRecruitmentStart={handleStartChange} onChangeRecruitmentEnd={handleEndChange} + disabled={isFormDisabled} /> )} diff --git a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/DateTimeRangePicker/DateTimeRangePicker.tsx b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/DateTimeRangePicker/DateTimeRangePicker.tsx index c7f00929f..d900a67e2 100644 --- a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/DateTimeRangePicker/DateTimeRangePicker.tsx +++ b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/DateTimeRangePicker/DateTimeRangePicker.tsx @@ -11,6 +11,8 @@ interface DateTimeRangePickerProps { onChangeRecruitmentStart: (date: Date | null) => void; onChangeRecruitmentEnd: (date: Date | null) => void; disabledEnd?: boolean; + /** 폼 전체 비활성화. 두 입력을 잠그고 열린 패널을 닫는다 */ + disabled?: boolean; } const DateTimeRangePicker = ({ @@ -19,6 +21,7 @@ const DateTimeRangePicker = ({ onChangeRecruitmentStart, onChangeRecruitmentEnd, disabledEnd = false, + disabled = false, }: DateTimeRangePickerProps) => { const [activePicker, setActivePicker] = useState(null); const containerRef = useRef(null); @@ -49,12 +52,16 @@ const DateTimeRangePicker = ({ }); }, [disabledEnd]); + // 잠긴 동안은 열려 있던 패널도 숨긴다. 상태를 바꾸지 않고 파생시켜 effect 없이 처리한다 + const visiblePicker = disabled ? null : activePicker; + return ( {/* 모집 시작 기간 */} togglePicker('start')} + disabled={disabled} + $isActive={visiblePicker === 'start'} + onClick={() => !disabled && togglePicker('start')} > {formatRecruitmentDateTime(recruitmentStart) || '모집 시작'} @@ -63,19 +70,19 @@ const DateTimeRangePicker = ({ {/* 모집 마감 기간 */} !disabledEnd && togglePicker('end')} + disabled={disabled || disabledEnd} + $isActive={visiblePicker === 'end'} + onClick={() => !disabled && !disabledEnd && togglePicker('end')} > {formatRecruitmentDateTime(recruitmentEnd) || '모집 종료'} - {activePicker && ( + {visiblePicker && ( Date: Sun, 6 Sep 2026 17:40:14 +0900 Subject: [PATCH 13/28] =?UTF-8?q?fix(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=88=98=EC=A0=95=20=EC=A4=91=20?= =?UTF-8?q?=EB=B0=B1=EA=B7=B8=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=EC=97=90=20=ED=8F=BC?= =?UTF-8?q?=EC=9D=B4=20=EC=82=AC=EB=9D=BC=EC=A7=80=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 재조회 실패 시 isError여도 캐시된 article이 남으므로 쓸 데이터가 없을 때만 오류 화면을 보여준다 - 초기 조회 실패는 article이 없어 기존과 같이 오류 화면으로 간다 --- .../pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx index 27b3eb1e2..817a51bf4 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx @@ -131,8 +131,10 @@ const PromotionEditTab = () => { if (isEdit && isLoading) return ; - // 목록 조회 자체가 실패한 것과 글이 없는 것을 구분한다. 실패를 "삭제됨"으로 보여주면 사용자가 잘못된 판단을 한다 - if (isEdit && isError) { + // 목록 조회 자체가 실패한 것과 글이 없는 것을 구분한다. 실패를 "삭제됨"으로 보여주면 사용자가 잘못된 판단을 한다. + // 단, 편집 중 백그라운드 재조회(refetchInterval·포커스)가 실패하면 isError여도 캐시된 article이 남으므로 + // 쓸 데이터가 없을 때만 오류 화면으로 바꾼다. 안 그러면 편집 중인 폼이 통째로 사라진다. + if (isEdit && isError && !article) { return ( {isCompact && goToList()} />} From 6c75143a7a9379cabe72b552cf5147bf06df769b Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 8 Sep 2026 20:42:31 +0900 Subject: [PATCH 14/28] =?UTF-8?q?refactor(admin):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A0=95=EB=A0=AC=20=EA=B7=B8=EB=A6=AC=EB=93=9C?= =?UTF-8?q?=EC=99=80=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=ED=9B=85=EC=9D=84=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EA=B3=B5=ED=86=B5=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EC=98=AC=EB=A6=B0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PhotoEditTab 안에만 있던 FeedImageGrid·useDragSort·reorderItems·FeedItem을 components/ImageSortGrid로 옮긴다. 홍보 이미지 필드도 같은 정렬 UI를 쓴다. - FeedImageGrid -> ImageSortGrid, FeedItem -> ImageItem으로 이름을 바꾼다. 두 화면이 공유하는 순간 Feed라는 이름이 사실과 달라진다 - onRetry를 optional로 내린다. 항목별 재시도는 활동 사진에만 있다 - 그리드 마지막 칸에 붙일 children 슬롯을 연다. data-card-index는 사진에만 붙어 있어 드롭 위치 계산에 영향이 없다 --- .../CardMeta/CardMeta.styles.ts | 0 .../PromotionCardView}/CardMeta/CardMeta.tsx | 0 .../DdayBadge/DdayBadge.styles.ts | 0 .../DdayBadge/DdayBadge.tsx | 0 .../PromotionCardView.styles.ts} | 0 .../AdminMoreMenu.stories.tsx} | 0 .../AdminMoreMenu.styles.ts} | 0 .../AdminMoreMenu.tsx} | 0 .../ImageSortGrid/ImageSortGrid.stories.tsx} | 32 +++++----- .../ImageSortGrid/ImageSortGrid.styles.ts} | 0 .../ImageSortGrid/ImageSortGrid.tsx} | 34 +++++++---- .../ImageSortGrid/reorderItems.test.ts | 49 +++++++++++++++ .../components/ImageSortGrid/reorderItems.ts | 13 ++++ .../ImageSortGrid}/types.ts | 4 +- .../ImageSortGrid}/useDragSort.ts | 14 ++--- .../tabs/PhotoEditTab/PhotoEditTabDesktop.tsx | 18 +++--- .../tabs/PhotoEditTab/PhotoEditTabMobile.tsx | 18 +++--- .../tabs/PhotoEditTab/hooks/useFeedItems.ts | 10 +++- .../tabs/PhotoEditTab/photoEditUtils.test.ts | 60 +++---------------- .../tabs/PhotoEditTab/photoEditUtils.ts | 24 +++----- 20 files changed, 147 insertions(+), 129 deletions(-) rename frontend/src/{pages/PromotionPage/components/list/PromotionCard => components/promotion/PromotionCardView}/CardMeta/CardMeta.styles.ts (100%) rename frontend/src/{pages/PromotionPage/components/list/PromotionCard => components/promotion/PromotionCardView}/CardMeta/CardMeta.tsx (100%) rename frontend/src/{pages/PromotionPage/components/list/PromotionCard => components/promotion/PromotionCardView}/DdayBadge/DdayBadge.styles.ts (100%) rename frontend/src/{pages/PromotionPage/components/list/PromotionCard => components/promotion/PromotionCardView}/DdayBadge/DdayBadge.tsx (100%) rename frontend/src/{pages/PromotionPage/components/list/PromotionCard/PromotionCard.styles.ts => components/promotion/PromotionCardView/PromotionCardView.styles.ts} (100%) rename frontend/src/pages/AdminPage/components/{ApplicationMenu/ApplicationMenu.stories.tsx => AdminMoreMenu/AdminMoreMenu.stories.tsx} (100%) rename frontend/src/pages/AdminPage/components/{ApplicationMenu/ApplicationMenu.styles.ts => AdminMoreMenu/AdminMoreMenu.styles.ts} (100%) rename frontend/src/pages/AdminPage/components/{ApplicationMenu/ApplicationMenu.tsx => AdminMoreMenu/AdminMoreMenu.tsx} (100%) rename frontend/src/pages/AdminPage/{tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx => components/ImageSortGrid/ImageSortGrid.stories.tsx} (78%) rename frontend/src/pages/AdminPage/{tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.styles.ts => components/ImageSortGrid/ImageSortGrid.styles.ts} (100%) rename frontend/src/pages/AdminPage/{tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.tsx => components/ImageSortGrid/ImageSortGrid.tsx} (82%) create mode 100644 frontend/src/pages/AdminPage/components/ImageSortGrid/reorderItems.test.ts create mode 100644 frontend/src/pages/AdminPage/components/ImageSortGrid/reorderItems.ts rename frontend/src/pages/AdminPage/{tabs/PhotoEditTab => components/ImageSortGrid}/types.ts (67%) rename frontend/src/pages/AdminPage/{tabs/PhotoEditTab/hooks => components/ImageSortGrid}/useDragSort.ts (94%) diff --git a/frontend/src/pages/PromotionPage/components/list/PromotionCard/CardMeta/CardMeta.styles.ts b/frontend/src/components/promotion/PromotionCardView/CardMeta/CardMeta.styles.ts similarity index 100% rename from frontend/src/pages/PromotionPage/components/list/PromotionCard/CardMeta/CardMeta.styles.ts rename to frontend/src/components/promotion/PromotionCardView/CardMeta/CardMeta.styles.ts diff --git a/frontend/src/pages/PromotionPage/components/list/PromotionCard/CardMeta/CardMeta.tsx b/frontend/src/components/promotion/PromotionCardView/CardMeta/CardMeta.tsx similarity index 100% rename from frontend/src/pages/PromotionPage/components/list/PromotionCard/CardMeta/CardMeta.tsx rename to frontend/src/components/promotion/PromotionCardView/CardMeta/CardMeta.tsx diff --git a/frontend/src/pages/PromotionPage/components/list/PromotionCard/DdayBadge/DdayBadge.styles.ts b/frontend/src/components/promotion/PromotionCardView/DdayBadge/DdayBadge.styles.ts similarity index 100% rename from frontend/src/pages/PromotionPage/components/list/PromotionCard/DdayBadge/DdayBadge.styles.ts rename to frontend/src/components/promotion/PromotionCardView/DdayBadge/DdayBadge.styles.ts diff --git a/frontend/src/pages/PromotionPage/components/list/PromotionCard/DdayBadge/DdayBadge.tsx b/frontend/src/components/promotion/PromotionCardView/DdayBadge/DdayBadge.tsx similarity index 100% rename from frontend/src/pages/PromotionPage/components/list/PromotionCard/DdayBadge/DdayBadge.tsx rename to frontend/src/components/promotion/PromotionCardView/DdayBadge/DdayBadge.tsx diff --git a/frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.styles.ts b/frontend/src/components/promotion/PromotionCardView/PromotionCardView.styles.ts similarity index 100% rename from frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.styles.ts rename to frontend/src/components/promotion/PromotionCardView/PromotionCardView.styles.ts diff --git a/frontend/src/pages/AdminPage/components/ApplicationMenu/ApplicationMenu.stories.tsx b/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.stories.tsx similarity index 100% rename from frontend/src/pages/AdminPage/components/ApplicationMenu/ApplicationMenu.stories.tsx rename to frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.stories.tsx diff --git a/frontend/src/pages/AdminPage/components/ApplicationMenu/ApplicationMenu.styles.ts b/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.styles.ts similarity index 100% rename from frontend/src/pages/AdminPage/components/ApplicationMenu/ApplicationMenu.styles.ts rename to frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.styles.ts diff --git a/frontend/src/pages/AdminPage/components/ApplicationMenu/ApplicationMenu.tsx b/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.tsx similarity index 100% rename from frontend/src/pages/AdminPage/components/ApplicationMenu/ApplicationMenu.tsx rename to frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.tsx diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx b/frontend/src/pages/AdminPage/components/ImageSortGrid/ImageSortGrid.stories.tsx similarity index 78% rename from frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx rename to frontend/src/pages/AdminPage/components/ImageSortGrid/ImageSortGrid.stories.tsx index 5dfbf81d8..d8cdd9a3f 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.stories.tsx +++ b/frontend/src/pages/AdminPage/components/ImageSortGrid/ImageSortGrid.stories.tsx @@ -1,9 +1,9 @@ import { useRef } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import type { FeedItem } from '../../types'; -import { FeedImageGrid } from './FeedImageGrid'; +import type { ImageItem } from './types'; +import { ImageSortGrid } from './ImageSortGrid'; -const img = (seed: string): FeedItem => ({ +const img = (seed: string): ImageItem => ({ type: 'uploaded', url: `https://picsum.photos/seed/${seed}/246/320`, }); @@ -11,7 +11,7 @@ const img = (seed: string): FeedItem => ({ const local = ( seed: string, status: 'pending' | 'uploading' | 'failed', -): FeedItem => ({ +): ImageItem => ({ type: 'local', file: new File([], `${seed}.jpg`), previewUrl: `https://picsum.photos/seed/${seed}/246/320`, @@ -19,23 +19,23 @@ const local = ( }); const Wrapper = ({ - feedItems, + items, isLoading = false, dragIndex = null, dropPosition = null, columns = 3, }: { - feedItems: FeedItem[]; + items: ImageItem[]; isLoading?: boolean; dragIndex?: number | null; - dropPosition?: Parameters[0]['dropPosition']; + dropPosition?: Parameters[0]['dropPosition']; columns?: number; }) => { const gridRef = useRef(null); return (
- , + render: () => , }; export const WithPending: Story = { render: () => ( ( ( ( ( diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.styles.ts b/frontend/src/pages/AdminPage/components/ImageSortGrid/ImageSortGrid.styles.ts similarity index 100% rename from frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.styles.ts rename to frontend/src/pages/AdminPage/components/ImageSortGrid/ImageSortGrid.styles.ts diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.tsx b/frontend/src/pages/AdminPage/components/ImageSortGrid/ImageSortGrid.tsx similarity index 82% rename from frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.tsx rename to frontend/src/pages/AdminPage/components/ImageSortGrid/ImageSortGrid.tsx index 306a7b478..644ab2569 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/components/FeedImageGrid/FeedImageGrid.tsx +++ b/frontend/src/pages/AdminPage/components/ImageSortGrid/ImageSortGrid.tsx @@ -1,11 +1,11 @@ import { useLayoutEffect, useState } from 'react'; import ClearButtonIcon from '@/assets/images/icons/dark_clear_button_icon.svg?react'; -import { DropPosition } from '../../hooks/useDragSort'; -import { FeedItem } from '../../types'; -import * as Styled from './FeedImageGrid.styles'; +import * as Styled from './ImageSortGrid.styles'; +import { ImageItem } from './types'; +import { DropPosition } from './useDragSort'; -interface FeedImageGridProps { - feedItems: FeedItem[]; +interface ImageSortGridProps { + items: ImageItem[]; gridRef: React.RefObject; dragIndex: number | null; dropPosition: DropPosition; @@ -13,7 +13,10 @@ interface FeedImageGridProps { columns?: number; onMouseDown: (e: React.MouseEvent, index: number) => void; onDelete: (index: number) => void; - onRetry: (index: number) => void; + /** 항목별 업로드 재시도가 있는 화면(활동 사진)에서만 넘긴다 */ + onRetry?: (index: number) => void; + /** 그리드 마지막 칸에 붙일 요소(홍보 화면의 이미지 추가 타일) */ + children?: React.ReactNode; } const calcDividerStyle = ( @@ -41,8 +44,8 @@ const calcDividerStyle = ( return { x, top: refRect.top - gridRect.top, height: refRect.height }; }; -export const FeedImageGrid = ({ - feedItems, +export const ImageSortGrid = ({ + items, gridRef, dragIndex, dropPosition, @@ -51,7 +54,8 @@ export const FeedImageGrid = ({ onMouseDown, onDelete, onRetry, -}: FeedImageGridProps) => { + children, +}: ImageSortGridProps) => { const dividerIndex = dropPosition ? dropPosition.side === 'before' ? dropPosition.index @@ -78,7 +82,7 @@ export const FeedImageGrid = ({ return ( - {feedItems.map((item, index) => { + {items.map((item, index) => { const src = item.type === 'uploaded' ? item.url : item.previewUrl; const status = item.type === 'local' ? item.status : undefined; @@ -108,9 +112,11 @@ export const FeedImageGrid = ({ {status === 'failed' && ( 실패 - onRetry(index)}> - 재전송 - + {onRetry && ( + onRetry(index)}> + 재전송 + + )} )} {status === 'pending' && ( @@ -130,6 +136,8 @@ export const FeedImageGrid = ({ ); })} + {children} + {divider && ( ({ type: 'uploaded', url }); + +describe('reorderItems', () => { + const items: ImageItem[] = [ + makeUploaded('a'), + makeUploaded('b'), + makeUploaded('c'), + makeUploaded('d'), + ]; + + it('앞에서 뒤로 이동한다 (0 → 2)', () => { + const result = reorderItems(items, 0, 2); + expect(result.map((i) => (i as { url: string }).url)).toEqual([ + 'b', + 'a', + 'c', + 'd', + ]); + }); + + it('뒤에서 앞으로 이동한다 (3 → 1)', () => { + const result = reorderItems(items, 3, 1); + expect(result.map((i) => (i as { url: string }).url)).toEqual([ + 'a', + 'd', + 'b', + 'c', + ]); + }); + + it('같은 위치로 이동해도 순서가 유지된다', () => { + const result = reorderItems(items, 1, 1); + expect(result.map((i) => (i as { url: string }).url)).toEqual([ + 'a', + 'b', + 'c', + 'd', + ]); + }); + + it('원본 배열을 변경하지 않는다 (불변성)', () => { + reorderItems(items, 0, 3); + expect(items).toHaveLength(4); + expect((items[0] as { url: string }).url).toBe('a'); + }); +}); diff --git a/frontend/src/pages/AdminPage/components/ImageSortGrid/reorderItems.ts b/frontend/src/pages/AdminPage/components/ImageSortGrid/reorderItems.ts new file mode 100644 index 000000000..857bb29fb --- /dev/null +++ b/frontend/src/pages/AdminPage/components/ImageSortGrid/reorderItems.ts @@ -0,0 +1,13 @@ +import { ImageItem } from './types'; + +export const reorderItems = ( + items: ImageItem[], + dragIndex: number, + targetIndex: number, +): ImageItem[] => { + const next = [...items]; + const [moved] = next.splice(dragIndex, 1); + const insertAt = dragIndex < targetIndex ? targetIndex - 1 : targetIndex; + next.splice(insertAt, 0, moved); + return next; +}; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/types.ts b/frontend/src/pages/AdminPage/components/ImageSortGrid/types.ts similarity index 67% rename from frontend/src/pages/AdminPage/tabs/PhotoEditTab/types.ts rename to frontend/src/pages/AdminPage/components/ImageSortGrid/types.ts index 062556ed4..9b307b506 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/types.ts +++ b/frontend/src/pages/AdminPage/components/ImageSortGrid/types.ts @@ -1,5 +1,3 @@ -// 활동사진 탭에서 사용하는 피드 아이템 타입 정의 - export interface UploadedItem { type: 'uploaded'; url: string; @@ -12,6 +10,6 @@ export interface LocalItem { status: 'pending' | 'uploading' | 'failed'; } -export type FeedItem = UploadedItem | LocalItem; +export type ImageItem = UploadedItem | LocalItem; export type ItemStatus = LocalItem['status']; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts b/frontend/src/pages/AdminPage/components/ImageSortGrid/useDragSort.ts similarity index 94% rename from frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts rename to frontend/src/pages/AdminPage/components/ImageSortGrid/useDragSort.ts index 84cdf7469..817ce75d7 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useDragSort.ts +++ b/frontend/src/pages/AdminPage/components/ImageSortGrid/useDragSort.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { reorderItems } from '../photoEditUtils'; -import { FeedItem } from '../types'; +import { reorderItems } from './reorderItems'; +import { ImageItem } from './types'; export type DropPosition = { index: number; side: 'before' | 'after' } | null; @@ -8,14 +8,14 @@ const DRAG_THRESHOLD = 5; interface UseDragSortOptions { disabled?: boolean; - onReorder: (items: FeedItem[]) => void; - feedItemsRef: React.RefObject; + onReorder: (items: ImageItem[]) => void; + itemsRef: React.RefObject; } export const useDragSort = ({ disabled, onReorder, - feedItemsRef, + itemsRef, }: UseDragSortOptions) => { const gridRef = useRef(null); const dragStartRef = useRef<{ index: number; x: number; y: number } | null>( @@ -120,7 +120,7 @@ export const useDragSort = ({ if (pos !== null) { const fromIndex = dragStartRef.current.index; const targetIndex = pos.side === 'after' ? pos.index + 1 : pos.index; - const current = feedItemsRef.current; + const current = itemsRef.current; if (fromIndex !== targetIndex && fromIndex < current.length) { onReorder(reorderItems(current, fromIndex, targetIndex)); } @@ -139,7 +139,7 @@ export const useDragSort = ({ window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; - }, [getDropPositionFromPoint, onReorder, feedItemsRef]); + }, [getDropPositionFromPoint, onReorder, itemsRef]); return { gridRef, dragIndex, dropPosition, handleMouseDown }; }; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTabDesktop.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTabDesktop.tsx index 27b822dfe..5b0eda1cd 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTabDesktop.tsx +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTabDesktop.tsx @@ -4,15 +4,15 @@ import { ADMIN_EVENT } from '@/constants/eventName'; import { MAX_FILE_COUNT } from '@/constants/uploadLimit'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; -import { FeedImageGrid } from './components/FeedImageGrid/FeedImageGrid'; -import { useDragSort } from './hooks/useDragSort'; +import { ImageSortGrid } from '@/pages/AdminPage/components/ImageSortGrid/ImageSortGrid'; +import { useDragSort } from '@/pages/AdminPage/components/ImageSortGrid/useDragSort'; import * as Styled from './PhotoEditTab.styles'; -import { FeedItem } from './types'; +import { ImageItem } from '@/pages/AdminPage/components/ImageSortGrid/types'; interface PhotoEditTabDesktopProps { - feedItems: FeedItem[]; - feedItemsRef: React.MutableRefObject; - setFeedItems: React.Dispatch>; + feedItems: ImageItem[]; + feedItemsRef: React.MutableRefObject; + setFeedItems: React.Dispatch>; isLoading: boolean; pendingChanges: boolean; addFiles: (files: File[]) => void; @@ -41,7 +41,7 @@ const PhotoEditTabDesktop = ({ const { gridRef, dragIndex, dropPosition, handleMouseDown } = useDragSort({ disabled: isLoading, onReorder: setFeedItems, - feedItemsRef, + itemsRef: feedItemsRef, }); const handleAddClick = () => { @@ -120,8 +120,8 @@ const PhotoEditTabDesktop = ({ 최대 {MAX_FILE_COUNT}장 ) : ( - ; - setFeedItems: React.Dispatch>; + feedItems: ImageItem[]; + feedItemsRef: React.MutableRefObject; + setFeedItems: React.Dispatch>; isLoading: boolean; pendingChanges: boolean; addFiles: (files: File[]) => void; @@ -40,7 +40,7 @@ const PhotoEditTabMobile = ({ const { gridRef, dragIndex, dropPosition, handleMouseDown } = useDragSort({ disabled: isLoading, onReorder: setFeedItems, - feedItemsRef, + itemsRef: feedItemsRef, }); const inputRef = useRef(null); @@ -107,8 +107,8 @@ const PhotoEditTabMobile = ({ 활동사진 수정하기 - { const { mutate: uploadFeed, isPending: isUploading } = useUploadFeed(); const { mutate: updateFeed, isPending: isUpdating } = useUpdateFeed(); - const [feedItems, setFeedItems] = useState([]); - const feedItemsRef = useRef(feedItems); + const [feedItems, setFeedItems] = useState([]); + const feedItemsRef = useRef(feedItems); const isLoading = isUploading || isUpdating; const pendingChanges = hasPendingChanges(feedItems, originalFeeds); diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts index 1b9f376ad..23d2db7a1 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts @@ -1,14 +1,13 @@ import { MAX_FILE_COUNT, MAX_FILE_SIZE } from '@/constants/uploadLimit'; +import { ImageItem } from '@/pages/AdminPage/components/ImageSortGrid/types'; import { findOversizedFile, hasPendingChanges, - reorderItems, sliceToLimit, } from './photoEditUtils'; -import { FeedItem } from './types'; -const makeUploaded = (url: string): FeedItem => ({ type: 'uploaded', url }); -const makeLocal = (name: string): FeedItem => ({ +const makeUploaded = (url: string): ImageItem => ({ type: 'uploaded', url }); +const makeLocal = (name: string): ImageItem => ({ type: 'local', file: new File([''], name, { type: 'image/jpeg' }), previewUrl: `blob:${name}`, @@ -60,69 +59,24 @@ describe('findOversizedFile', () => { }); }); -describe('reorderItems', () => { - const items: FeedItem[] = [ - makeUploaded('a'), - makeUploaded('b'), - makeUploaded('c'), - makeUploaded('d'), - ]; - - it('앞에서 뒤로 이동한다 (0 → 2)', () => { - const result = reorderItems(items, 0, 2); - expect(result.map((i) => (i as { url: string }).url)).toEqual([ - 'b', - 'a', - 'c', - 'd', - ]); - }); - - it('뒤에서 앞으로 이동한다 (3 → 1)', () => { - const result = reorderItems(items, 3, 1); - expect(result.map((i) => (i as { url: string }).url)).toEqual([ - 'a', - 'd', - 'b', - 'c', - ]); - }); - - it('같은 위치로 이동해도 순서가 유지된다', () => { - const result = reorderItems(items, 1, 1); - expect(result.map((i) => (i as { url: string }).url)).toEqual([ - 'a', - 'b', - 'c', - 'd', - ]); - }); - - it('원본 배열을 변경하지 않는다 (불변성)', () => { - reorderItems(items, 0, 3); - expect(items).toHaveLength(4); - expect((items[0] as { url: string }).url).toBe('a'); - }); -}); - describe('hasPendingChanges', () => { it('local 아이템이 있으면 true를 반환한다', () => { - const feedItems: FeedItem[] = [makeUploaded('a'), makeLocal('new.jpg')]; + const feedItems: ImageItem[] = [makeUploaded('a'), makeLocal('new.jpg')]; expect(hasPendingChanges(feedItems, ['a'])).toBe(true); }); it('uploaded URL이 원본과 동일하면 false를 반환한다', () => { - const feedItems: FeedItem[] = [makeUploaded('a'), makeUploaded('b')]; + const feedItems: ImageItem[] = [makeUploaded('a'), makeUploaded('b')]; expect(hasPendingChanges(feedItems, ['a', 'b'])).toBe(false); }); it('이미지가 삭제되면 true를 반환한다', () => { - const feedItems: FeedItem[] = [makeUploaded('a')]; + const feedItems: ImageItem[] = [makeUploaded('a')]; expect(hasPendingChanges(feedItems, ['a', 'b'])).toBe(true); }); it('순서가 바뀌면 true를 반환한다', () => { - const feedItems: FeedItem[] = [makeUploaded('b'), makeUploaded('a')]; + const feedItems: ImageItem[] = [makeUploaded('b'), makeUploaded('a')]; expect(hasPendingChanges(feedItems, ['a', 'b'])).toBe(true); }); diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts index 2d8f7e1be..8322505bb 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts @@ -1,5 +1,9 @@ import { MAX_FILE_COUNT, MAX_FILE_SIZE } from '@/constants/uploadLimit'; -import { FeedItem, LocalItem, UploadedItem } from './types'; +import { + ImageItem, + LocalItem, + UploadedItem, +} from '@/pages/AdminPage/components/ImageSortGrid/types'; export const findOversizedFile = (files: File[]): File | undefined => files.find((f) => f.size > MAX_FILE_SIZE); @@ -9,20 +13,8 @@ export const sliceToLimit = (files: File[], currentCount: number): File[] => { return files.slice(0, remaining); }; -export const reorderItems = ( - items: FeedItem[], - dragIndex: number, - targetIndex: number, -): FeedItem[] => { - const next = [...items]; - const [moved] = next.splice(dragIndex, 1); - const insertAt = dragIndex < targetIndex ? targetIndex - 1 : targetIndex; - next.splice(insertAt, 0, moved); - return next; -}; - export const hasPendingChanges = ( - feedItems: FeedItem[], + feedItems: ImageItem[], originalFeeds: string[], ): boolean => { if (feedItems.some((item) => item.type === 'local')) return true; @@ -32,10 +24,10 @@ export const hasPendingChanges = ( return currentUrls.join() !== originalFeeds.join(); }; -export const extractLocalItems = (feedItems: FeedItem[]): LocalItem[] => +export const extractLocalItems = (feedItems: ImageItem[]): LocalItem[] => feedItems.filter((item): item is LocalItem => item.type === 'local'); -export const extractUploadedUrls = (feedItems: FeedItem[]): string[] => +export const extractUploadedUrls = (feedItems: ImageItem[]): string[] => feedItems .filter((item): item is UploadedItem => item.type === 'uploaded') .map((item) => item.url); From 4677664df6f564efdafce9ea3217a19fe4c9ac3b Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 8 Sep 2026 20:42:39 +0900 Subject: [PATCH 15/28] =?UTF-8?q?feat(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=A5=BC=20=EB=81=8C=EC=96=B4?= =?UTF-8?q?=EC=84=9C=20=EC=88=9C=EC=84=9C=EB=A5=BC=20=EB=B0=94=EA=BF=80=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홍보 상세는 images 배열 순서대로 이미지를 세로로 쌓는데, 관리자가 그 순서를 정할 방법이 없었다. 새 이미지는 항상 뒤로 붙었다. - existingImages(string[]) + localFiles(LocalImage[]) 두 배열을 images(ImageItem[]) 하나로 합친다. 순서는 배열 인덱스로만 표현되는데 "올라간 것/안 올라간 것"이라는 다른 축으로 쪼개 두면 순서를 담을 수 없다 - 저장 시 업로드 결과를 file -> url Map으로 받아 화면 순서 자리에 그대로 꽂는다. 업로드에 실패한 항목은 로컬로 남겨 다시 시도할 수 있게 한다 --- .../PromotionImageField.styles.ts | 70 +---------- .../PromotionImageField.test.tsx | 8 +- .../PromotionImageField.tsx | 85 ++++++------- .../hooks/usePromotionForm.test.ts | 117 ++++++++++++++++++ .../PromotionTab/hooks/usePromotionForm.ts | 97 +++++++++------ .../PromotionTab/utils/promotionForm.test.ts | 22 ++-- .../tabs/PromotionTab/utils/promotionForm.ts | 27 ++-- 7 files changed, 244 insertions(+), 182 deletions(-) create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.test.ts diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.styles.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.styles.ts index 4215739c2..5bc511cce 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.styles.ts @@ -1,5 +1,4 @@ import styled from 'styled-components'; -import { media } from '@/styles/mediaQuery'; import { colors } from '@/styles/theme/colors'; export const Header = styled.div` @@ -19,79 +18,16 @@ export const Count = styled.span` color: ${colors.gray[600]}; `; -export const Grid = styled.div` - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 12px; - - ${media.tablet} { - grid-template-columns: repeat(3, 1fr); - gap: 8px; - } -`; - -export const Item = styled.div` - position: relative; - aspect-ratio: 1; - border-radius: 12px; - overflow: hidden; - background: ${colors.gray[100]}; -`; - -export const Photo = styled.img` - width: 100%; - height: 100%; - object-fit: cover; - display: block; -`; - -export const PendingBadge = styled.span` - position: absolute; - left: 8px; - bottom: 8px; - padding: 2px 8px; - border-radius: 999px; - background: rgba(17, 17, 17, 0.7); - color: ${colors.base.white}; - font-size: 0.75rem; - font-weight: 500; -`; - -export const RemoveButton = styled.button` - position: absolute; - top: 6px; - right: 6px; - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 0; - border: none; - border-radius: 50%; - background: rgba(255, 255, 255, 0.9); - cursor: pointer; - - svg { - width: 14px; - height: 14px; - } - - &:disabled { - opacity: 0.4; - cursor: not-allowed; - } -`; - +/* ImageSortGrid 안의 마지막 칸. 사진 타일과 같은 비율이어야 줄이 맞는다 */ export const AddTile = styled.button` display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; - aspect-ratio: 1; + width: 100%; + aspect-ratio: 123 / 160; border: 2px dashed ${colors.gray[400]}; - border-radius: 12px; background: ${colors.gray[50]}; color: ${colors.gray[600]}; font-size: 0.875rem; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.test.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.test.tsx index 9abdc790e..6b85568ce 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.test.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.test.tsx @@ -7,11 +7,11 @@ const renderField = () => { const onReject = jest.fn(); const { container } = render( , ); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.tsx index 70ea5db7d..a3e44b997 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.tsx @@ -1,41 +1,51 @@ -import { useRef } from 'react'; -import ClearButtonIcon from '@/assets/images/icons/dark_clear_button_icon.svg?react'; +import { useEffect, useRef } from 'react'; import { PROMOTION_IMAGE_MAX_COUNT } from '@/constants/adminFieldLimits'; import { ALLOWED_IMAGE_TYPES, MAX_FILE_SIZE } from '@/constants/uploadLimit'; -import { LocalImage } from '../../utils/promotionForm'; +import { ImageSortGrid } from '@/pages/AdminPage/components/ImageSortGrid/ImageSortGrid'; +import { ImageItem } from '@/pages/AdminPage/components/ImageSortGrid/types'; +import { useDragSort } from '@/pages/AdminPage/components/ImageSortGrid/useDragSort'; import * as Styled from './PromotionImageField.styles'; interface PromotionImageFieldProps { - existingImages: string[]; - localFiles: LocalImage[]; + images: ImageItem[]; + columns: number; disabled?: boolean; onAddFiles: (files: File[]) => void; - onRemoveExisting: (url: string) => void; - onRemoveLocal: (index: number) => void; + onRemove: (index: number) => void; + onReorder: (images: ImageItem[]) => void; /** 파일 제한에 걸렸을 때 안내 문구를 띄운다 */ onReject: (message: string) => void; } const PromotionImageField = ({ - existingImages, - localFiles, + images, + columns, disabled = false, onAddFiles, - onRemoveExisting, - onRemoveLocal, + onRemove, + onReorder, onReject, }: PromotionImageFieldProps) => { const inputRef = useRef(null); - const totalCount = existingImages.length + localFiles.length; - const isFull = totalCount >= PROMOTION_IMAGE_MAX_COUNT; + const imagesRef = useRef(images); + useEffect(() => { + imagesRef.current = images; + }, [images]); + + const { gridRef, dragIndex, dropPosition, handleMouseDown } = useDragSort({ + disabled, + onReorder, + itemsRef: imagesRef, + }); + + const isFull = images.length >= PROMOTION_IMAGE_MAX_COUNT; const handleFilesSelected = (e: React.ChangeEvent) => { const selected = Array.from(e.target.files ?? []); e.target.value = ''; if (selected.length === 0) return; - // accept는 선택창 필터일 뿐이라 "모든 파일"로 바꾸면 우회된다. 저장 시점에야 실패를 알지 않도록 여기서 막는다 const unsupported = selected.find( (file) => !(ALLOWED_IMAGE_TYPES as readonly string[]).includes(file.type), ); @@ -52,7 +62,7 @@ const PromotionImageField = ({ return; } - const remaining = PROMOTION_IMAGE_MAX_COUNT - totalCount; + const remaining = PROMOTION_IMAGE_MAX_COUNT - images.length; if (selected.length > remaining) { onReject( `이미지는 최대 ${PROMOTION_IMAGE_MAX_COUNT}장까지 등록할 수 있습니다.`, @@ -66,40 +76,20 @@ const PromotionImageField = ({ 행사 이미지 - {totalCount}/{PROMOTION_IMAGE_MAX_COUNT} + {images.length}/{PROMOTION_IMAGE_MAX_COUNT} - - {existingImages.map((url) => ( - - - onRemoveExisting(url)} - > - - - - ))} - - {localFiles.map(({ previewUrl }, index) => ( - - - 업로드 예정 - onRemoveLocal(index)} - > - - - - ))} - + {!isFull && ( 이미지 추가 )} - + JPG·PNG·WebP 등 이미지 파일, 장당 10MB 이하. 저장할 때 함께 업로드돼요. + 끌어서 순서를 바꿀 수 있어요. ({ + useCreatePromotionArticle: () => ({ mutateAsync: createArticle }), + useUpdatePromotionArticle: () => ({ mutateAsync: updateArticle }), + useUploadPromotionImages: () => ({ mutateAsync: uploadImages }), +})); + +const article: PromotionArticle = { + id: 'a1', + clubId: 'club-1', + clubName: '극예술연구회', + title: '봄 정기공연', + location: '한울관(E31) 302호', + latitude: 35.132367, + longitude: 129.106974, + eventStartDate: '2026-04-01T01:00:00Z', + eventEndDate: '2026-04-01T03:00:00Z', + description: '설명', + images: ['https://cdn/old1.png', 'https://cdn/old2.png'], +}; + +const makeFile = (name: string) => new File(['x'], name, { type: 'image/png' }); + +beforeEach(() => { + jest.clearAllMocks(); + global.URL.createObjectURL = jest.fn((file) => `blob:${(file as File).name}`); + global.URL.revokeObjectURL = jest.fn(); +}); + +describe('usePromotionForm 이미지 순서', () => { + it('새 파일을 앞으로 끌어다 놓으면 그 순서 그대로 저장된다', async () => { + const newFile = makeFile('new.png'); + uploadImages.mockResolvedValue({ + uploaded: [{ file: newFile, url: 'https://cdn/new.png' }], + failedFiles: [], + }); + updateArticle.mockResolvedValue({}); + + const { result } = renderHook(() => + usePromotionForm({ clubId: 'club-1', article }), + ); + + act(() => result.current.addFiles([newFile])); + // [old1, old2, new] → 새 파일을 맨 앞으로 + act(() => { + const [old1, old2, added] = result.current.values.images; + result.current.reorderImages([added, old1, old2]); + }); + + await act(async () => { + await result.current.save(); + }); + + await waitFor(() => expect(updateArticle).toHaveBeenCalled()); + expect(updateArticle.mock.calls[0][0].payload.images).toEqual([ + 'https://cdn/new.png', + 'https://cdn/old1.png', + 'https://cdn/old2.png', + ]); + }); + + it('업로드에 실패한 파일은 빠지고 나머지 순서는 유지된다', async () => { + const okFile = makeFile('ok.png'); + const badFile = makeFile('bad.png'); + uploadImages.mockResolvedValue({ + uploaded: [{ file: okFile, url: 'https://cdn/ok.png' }], + failedFiles: [badFile], + }); + updateArticle.mockResolvedValue({}); + + const { result } = renderHook(() => + usePromotionForm({ clubId: 'club-1', article }), + ); + + act(() => result.current.addFiles([badFile, okFile])); + // [old1, old2, bad, ok] → [bad, old1, ok, old2] + act(() => { + const [old1, old2, bad, ok] = result.current.values.images; + result.current.reorderImages([bad, old1, ok, old2]); + }); + + let saveResult; + await act(async () => { + saveResult = await result.current.save(); + }); + + expect(updateArticle.mock.calls[0][0].payload.images).toEqual([ + 'https://cdn/old1.png', + 'https://cdn/ok.png', + 'https://cdn/old2.png', + ]); + expect(saveResult).toEqual({ + status: 'partial', + articleId: 'a1', + failedCount: 1, + }); + }); + + it('삭제한 로컬 이미지의 previewUrl은 revoke한다', () => { + const { result } = renderHook(() => + usePromotionForm({ clubId: 'club-1', article }), + ); + + act(() => result.current.addFiles([makeFile('temp.png')])); + act(() => result.current.removeImage(2)); + + expect(global.URL.revokeObjectURL).toHaveBeenCalledWith('blob:temp.png'); + expect(result.current.values.images).toHaveLength(2); + }); +}); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts index 08fcdab5e..ad20174bd 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts @@ -5,6 +5,10 @@ import { useUpdatePromotionArticle, useUploadPromotionImages, } from '@/hooks/Queries/usePromotion'; +import { + ImageItem, + LocalItem, +} from '@/pages/AdminPage/components/ImageSortGrid/types'; import { PromotionArticle } from '@/types/promotion'; import { articleToFormValues, @@ -61,9 +65,9 @@ export const usePromotionForm = ({ }, [values]); useEffect( () => () => - valuesRef.current.localFiles.forEach(({ previewUrl }) => - URL.revokeObjectURL(previewUrl), - ), + valuesRef.current.images.forEach((item) => { + if (item.type === 'local') URL.revokeObjectURL(item.previewUrl); + }), [], ); @@ -72,52 +76,66 @@ export const usePromotionForm = ({ value: PromotionFormValues[K], ) => setValues((prev) => ({ ...prev, [key]: value })); - const addLocalFiles = (files: File[]) => + const addFiles = (files: File[]) => setValues((prev) => ({ ...prev, - localFiles: [ - ...prev.localFiles, - ...files.map((file) => ({ - file, - previewUrl: URL.createObjectURL(file), - })), + images: [ + ...prev.images, + ...files.map( + (file): LocalItem => ({ + type: 'local', + file, + previewUrl: URL.createObjectURL(file), + status: 'pending', + }), + ), ], })); - const removeLocalFile = (index: number) => + const removeImage = (index: number) => setValues((prev) => { - const target = prev.localFiles[index]; - if (target) URL.revokeObjectURL(target.previewUrl); - return { - ...prev, - localFiles: prev.localFiles.filter((_, i) => i !== index), - }; + const target = prev.images[index]; + if (target?.type === 'local') URL.revokeObjectURL(target.previewUrl); + return { ...prev, images: prev.images.filter((_, i) => i !== index) }; }); - const removeExistingImage = (url: string) => - setValues((prev) => ({ - ...prev, - existingImages: prev.existingImages.filter((image) => image !== url), - })); + const reorderImages = (images: ImageItem[]) => + setValues((prev) => ({ ...prev, images })); + /** + * 아직 안 올린 파일만 업로드하고, 화면 순서를 유지한 채 URL 목록을 만든다. + * 활동 사진처럼 "기존 → 새 것"으로 다시 세우지 않는 이유가 이것이다. + */ const uploadFiles = async (articleId: string) => { - const { uploaded, failedFiles } = await uploadImages({ - articleId, - files: values.localFiles.map(({ file }) => file), - }); - const uploadedUrls = uploaded.map(({ url }) => url); - const uploadedFiles = new Set(uploaded.map(({ file }) => file)); - // 올라간 파일은 서버 이미지로 옮겨 둔다. 일부 실패로 화면에 남았을 때 다시 저장해도 중복 업로드되지 않는다. + const localFiles = values.images + .filter((item): item is LocalItem => item.type === 'local') + .map(({ file }) => file); + + const { uploaded, failedFiles } = + localFiles.length > 0 + ? await uploadImages({ articleId, files: localFiles }) + : { uploaded: [], failedFiles: [] }; + const urlByFile = new Map(uploaded.map(({ file, url }) => [file, url])); + + // 올라간 파일만 제자리에서 uploaded로 바꾼다. 일부 실패로 화면에 남았을 때 다시 저장해도 중복 업로드되지 않는다. setValues((prev) => ({ ...prev, - existingImages: [...prev.existingImages, ...uploadedUrls], - localFiles: prev.localFiles.filter(({ file, previewUrl }) => { - const isUploaded = uploadedFiles.has(file); - if (isUploaded) URL.revokeObjectURL(previewUrl); - return !isUploaded; + images: prev.images.map((item) => { + if (item.type !== 'local') return item; + const url = urlByFile.get(item.file); + if (!url) return item; + URL.revokeObjectURL(item.previewUrl); + return { type: 'uploaded', url }; }), })); - return { uploadedUrls, failedCount: failedFiles.length }; + + const orderedUrls = values.images + .map((item) => + item.type === 'uploaded' ? item.url : urlByFile.get(item.file), + ) + .filter((url): url is string => Boolean(url)); + + return { orderedUrls, failedCount: failedFiles.length }; }; const save = async (): Promise => { @@ -140,8 +158,7 @@ export const usePromotionForm = ({ articleId = created.articleId; } - const { uploadedUrls, failedCount } = await uploadFiles(articleId); - const images = [...values.existingImages, ...uploadedUrls]; + const { orderedUrls: images, failedCount } = await uploadFiles(articleId); // PUT은 images를 1개 이상 요구한다. 작성에서 올릴 이미지가 없으면 PUT할 것도 없고, // 수정에서 여기 오는 건 검증을 통과한 이미지가 전부 업로드 실패한 경우뿐이다. @@ -181,9 +198,9 @@ export const usePromotionForm = ({ mode, values, setField, - addLocalFiles, - removeLocalFile, - removeExistingImage, + addFiles, + removeImage, + reorderImages, isSaving, save, }; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts index bd72bfda0..f8872aa0c 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts @@ -1,3 +1,4 @@ +import { ImageItem } from '@/pages/AdminPage/components/ImageSortGrid/types'; import { PromotionArticle } from '@/types/promotion'; import { articleToFormValues, @@ -18,13 +19,19 @@ const validValues: PromotionFormValues = { eventStart: new Date('2026-04-01T10:00:00+09:00'), eventEnd: new Date('2026-04-01T12:00:00+09:00'), description: '연극 정기공연입니다.', - existingImages: [], - localFiles: [], + images: [], }; -const makeLocalImage = (name: string) => ({ +const makeLocalImage = (name: string): ImageItem => ({ + type: 'local', file: new File(['x'], name, { type: 'image/png' }), previewUrl: `blob:${name}`, + status: 'pending', +}); + +const makeUploadedImage = (url: string): ImageItem => ({ + type: 'uploaded', + url, }); describe('BUILDING_OPTIONS', () => { @@ -85,13 +92,13 @@ describe('validatePromotionForm', () => { ); expect( validatePromotionForm( - { ...validValues, existingImages: ['https://cdn/a.png'] }, + { ...validValues, images: [makeUploadedImage('https://cdn/a.png')] }, 'edit', ), ).toBeNull(); expect( validatePromotionForm( - { ...validValues, localFiles: [makeLocalImage('a.png')] }, + { ...validValues, images: [makeLocalImage('a.png')] }, 'edit', ), ).toBeNull(); @@ -165,8 +172,9 @@ describe('articleToFormValues', () => { const values = articleToFormValues(article); expect(values.coordinates).toEqual({ lat: 35.132367, lng: 129.106974 }); expect(values.eventStart?.toISOString()).toBe('2026-04-01T01:00:00.000Z'); - expect(values.existingImages).toEqual(['https://cdn/a.png']); - expect(values.localFiles).toEqual([]); + expect(values.images).toEqual([ + { type: 'uploaded', url: 'https://cdn/a.png' }, + ]); }); it('좌표가 없으면 coordinates는 null', () => { diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts index f2d399060..cea18c004 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts @@ -5,6 +5,7 @@ import { PROMOTION_TITLE_MAX, } from '@/constants/adminFieldLimits'; import { clubLocations } from '@/constants/clubLocation'; +import { ImageItem } from '@/pages/AdminPage/components/ImageSortGrid/types'; import { CreatePromotionArticleRequest, PromotionArticle, @@ -15,12 +16,6 @@ export interface Coordinates { lng: number; } -/** 아직 올리지 않은 로컬 파일. previewUrl은 createObjectURL 결과라 버릴 때 revoke해야 한다 */ -export interface LocalImage { - file: File; - previewUrl: string; -} - export interface PromotionFormValues { title: string; location: string; @@ -28,9 +23,12 @@ export interface PromotionFormValues { eventStart: Date | null; eventEnd: Date | null; description: string; - /** 서버에 이미 올라간 이미지 URL (수정 시 삭제 가능) */ - existingImages: string[]; - localFiles: LocalImage[]; + /** + * 화면에 보이는 순서 그대로의 이미지 목록. 이미 올라간 것과 아직 안 올린 것이 + * 한 배열에 섞여 있어야 드래그로 순서를 바꿀 수 있다. + * local 항목의 previewUrl은 createObjectURL 결과라 버릴 때 revoke해야 한다. + */ + images: ImageItem[]; } export interface BuildingOption { @@ -71,8 +69,7 @@ export const createEmptyPromotionForm = (): PromotionFormValues => { eventStart: nextHour, eventEnd: nextHour, description: '', - existingImages: [], - localFiles: [], + images: [], }; }; @@ -94,8 +91,7 @@ export const articleToFormValues = ( eventStart: toDateOrNull(article.eventStartDate), eventEnd: toDateOrNull(article.eventEndDate), description: article.description, - existingImages: article.images ?? [], - localFiles: [], + images: (article.images ?? []).map((url) => ({ type: 'uploaded', url })), }); /** @@ -120,10 +116,7 @@ export const validatePromotionForm = ( if (!values.description.trim()) return '행사 설명을 입력해주세요.'; if (values.description.trim().length > PROMOTION_DESCRIPTION_MAX) return `행사 설명은 ${PROMOTION_DESCRIPTION_MAX}자 이내로 입력해주세요.`; - if ( - mode === 'edit' && - values.existingImages.length + values.localFiles.length === 0 - ) + if (mode === 'edit' && values.images.length === 0) return '이미지를 1장 이상 등록해주세요.'; return null; }; From 534eaf2f9bb94c54e80653444ed60ae3b61c66f4 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 8 Sep 2026 20:42:47 +0900 Subject: [PATCH 16/28] =?UTF-8?q?feat(promotion):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=98=EA=B3=A0=20=ED=96=89=EC=82=AC=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=EC=9D=BC=EC=9D=84=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EB=B3=B4=EC=97=AC=EC=A4=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 목록이 사용자와 같은 카드를 쓰려면 카드에서 동작을 떼어내야 한다. PromotionCard를 그대로 재사용하면 관리자 클릭이 USER_EVENT.PROMOTION_CARD_CLICKED로 집계돼 사용자 지표가 오염된다. - PromotionCardView는 표시만 한다. 클릭·트래킹은 감싸는 쪽이 붙인다 - CardMeta·DdayBadge를 components/promotion 아래로 옮긴다 - formatKSTDateRange를 추가해 카드에도 종료일을 보여준다. 카드 폭이 좁아 하루짜리는 요일까지, 여러 날이면 요일을 빼고 두 날짜만 쓴다. 같은 날인지는 KST 기준으로 판단한다 --- .../PromotionCardView/CardMeta/CardMeta.tsx | 9 +-- .../PromotionCardView.stories.tsx | 55 +++++++++++++++++++ .../PromotionCardView/PromotionCardView.tsx | 39 +++++++++++++ .../RelatedPromotionCard.tsx | 3 +- .../PromotionCard/PromotionCard.styles.ts | 5 ++ .../list/PromotionCard/PromotionCard.tsx | 26 ++------- frontend/src/utils/formatKSTDateTime.test.ts | 33 +++++++++++ frontend/src/utils/formatKSTDateTime.ts | 21 +++++++ 8 files changed, 164 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/promotion/PromotionCardView/PromotionCardView.stories.tsx create mode 100644 frontend/src/components/promotion/PromotionCardView/PromotionCardView.tsx create mode 100644 frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.styles.ts diff --git a/frontend/src/components/promotion/PromotionCardView/CardMeta/CardMeta.tsx b/frontend/src/components/promotion/PromotionCardView/CardMeta/CardMeta.tsx index b0c71ab58..8b192a906 100644 --- a/frontend/src/components/promotion/PromotionCardView/CardMeta/CardMeta.tsx +++ b/frontend/src/components/promotion/PromotionCardView/CardMeta/CardMeta.tsx @@ -1,16 +1,17 @@ import LocationIcon from '@/assets/images/icons/location_icon.svg?react'; import TimeIcon from '@/assets/images/icons/time_icon.svg?react'; -import { formatKSTDate } from '@/utils/formatKSTDateTime'; +import { formatKSTDateRange } from '@/utils/formatKSTDateTime'; import * as Styled from './CardMeta.styles'; interface CardMetaProps { title: string; location: string | null; startDate: string; + endDate: string; } -const CardMeta = ({ title, location, startDate }: CardMetaProps) => { - const formattedStartDate = formatKSTDate(startDate); +const CardMeta = ({ title, location, startDate, endDate }: CardMetaProps) => { + const formattedPeriod = formatKSTDateRange(startDate, endDate); return ( @@ -30,7 +31,7 @@ const CardMeta = ({ title, location, startDate }: CardMetaProps) => { - {formattedStartDate} + {formattedPeriod} diff --git a/frontend/src/components/promotion/PromotionCardView/PromotionCardView.stories.tsx b/frontend/src/components/promotion/PromotionCardView/PromotionCardView.stories.tsx new file mode 100644 index 000000000..bbe82fbdb --- /dev/null +++ b/frontend/src/components/promotion/PromotionCardView/PromotionCardView.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { PromotionArticle } from '@/types/promotion'; +import PromotionCardView from './PromotionCardView'; + +const base: PromotionArticle = { + id: 'a1', + clubId: 'club-1', + clubName: '극예술연구회', + title: 'EXODUS : 대탈출', + location: '부경대학교 나비센터 2층 소극장', + latitude: 35.132367, + longitude: 129.106974, + eventStartDate: '2026-11-29T04:00:00+09:00', + eventEndDate: '2026-11-30T02:00:00+09:00', + description: '설명', + images: ['https://picsum.photos/seed/promotion/400/400'], +}; + +const meta = { + title: 'Components/promotion/PromotionCardView', + component: PromotionCardView, + parameters: { layout: 'centered' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const 여러날_행사: Story = { args: { article: base } }; + +export const 하루짜리_행사: Story = { + args: { + article: { ...base, eventEndDate: '2026-11-29T22:00:00+09:00' }, + }, +}; + +export const 이미지_없음: Story = { + args: { article: { ...base, images: [] } }, +}; + +export const 종료된_행사: Story = { + args: { + article: { + ...base, + eventStartDate: '2020-01-01T10:00:00+09:00', + eventEndDate: '2020-01-01T12:00:00+09:00', + }, + }, +}; diff --git a/frontend/src/components/promotion/PromotionCardView/PromotionCardView.tsx b/frontend/src/components/promotion/PromotionCardView/PromotionCardView.tsx new file mode 100644 index 000000000..256a2e127 --- /dev/null +++ b/frontend/src/components/promotion/PromotionCardView/PromotionCardView.tsx @@ -0,0 +1,39 @@ +import { getDDay } from '@/pages/PromotionPage/utils/getDday'; +import { PromotionArticle } from '@/types/promotion'; +import CardMeta from './CardMeta/CardMeta'; +import DdayBadge from './DdayBadge/DdayBadge'; +import * as Styled from './PromotionCardView.styles'; + +interface PromotionCardViewProps { + article: PromotionArticle; +} + +/** + * 홍보 카드의 표시만 담당한다. 클릭·트래킹 같은 동작은 감싸는 쪽이 붙인다. + * 사용자 목록과 관리자 목록이 같은 뷰를 써야 관리자가 실제 노출 결과를 그대로 본다. + */ +const PromotionCardView = ({ article }: PromotionCardViewProps) => { + const dday = getDDay(article.eventStartDate, article.eventEndDate); + + return ( + + + + + + + + + + + + + ); +}; + +export default PromotionCardView; diff --git a/frontend/src/pages/PromotionPage/components/detail/RelatedPromotionSection/RelatedPromotionCard/RelatedPromotionCard.tsx b/frontend/src/pages/PromotionPage/components/detail/RelatedPromotionSection/RelatedPromotionCard/RelatedPromotionCard.tsx index ccad0f3c5..05051c1dc 100644 --- a/frontend/src/pages/PromotionPage/components/detail/RelatedPromotionSection/RelatedPromotionCard/RelatedPromotionCard.tsx +++ b/frontend/src/pages/PromotionPage/components/detail/RelatedPromotionSection/RelatedPromotionCard/RelatedPromotionCard.tsx @@ -1,5 +1,5 @@ +import CardMeta from '@/components/promotion/PromotionCardView/CardMeta/CardMeta'; import { PromotionArticle } from '@/types/promotion'; -import CardMeta from '../../../list/PromotionCard/CardMeta/CardMeta'; import ClubTag from '../../../list/PromotionCard/ClubTag/ClubTag'; import * as Styled from './RelatedPromotionCard.styles'; @@ -19,6 +19,7 @@ const RelatedPromotionCard = ({ article, onClick }: Props) => { title={article.title} location={article.location} startDate={article.eventStartDate} + endDate={article.eventEndDate} /> ); diff --git a/frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.styles.ts b/frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.styles.ts new file mode 100644 index 000000000..9c4a4453c --- /dev/null +++ b/frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.styles.ts @@ -0,0 +1,5 @@ +import styled from 'styled-components'; + +export const Clickable = styled.div` + cursor: pointer; +`; diff --git a/frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.tsx b/frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.tsx index f3b48f5ec..1ebaacde3 100644 --- a/frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.tsx +++ b/frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.tsx @@ -1,10 +1,8 @@ +import PromotionCardView from '@/components/promotion/PromotionCardView/PromotionCardView'; import { USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useNavigator from '@/hooks/useNavigator'; -import { getDDay } from '@/pages/PromotionPage/utils/getDday'; import { PromotionArticle } from '@/types/promotion'; -import CardMeta from './CardMeta/CardMeta'; -import DdayBadge from './DdayBadge/DdayBadge'; import * as Styled from './PromotionCard.styles'; interface PromotionCardProps { @@ -14,7 +12,6 @@ interface PromotionCardProps { const PromotionCard = ({ article }: PromotionCardProps) => { const trackEvent = useMixpanelTrack(); const handleLink = useNavigator(); - const dday = getDDay(article.eventStartDate, article.eventEndDate); const handleCardClick = () => { trackEvent(USER_EVENT.PROMOTION_CARD_CLICKED, { @@ -24,25 +21,10 @@ const PromotionCard = ({ article }: PromotionCardProps) => { handleLink(`/promotions/${article.id}`); }; - const imageUrl = article.images?.[0]; - return ( - - - - - - - - - - - - + + + ); }; diff --git a/frontend/src/utils/formatKSTDateTime.test.ts b/frontend/src/utils/formatKSTDateTime.test.ts index 510c3bfba..95860964d 100644 --- a/frontend/src/utils/formatKSTDateTime.test.ts +++ b/frontend/src/utils/formatKSTDateTime.test.ts @@ -1,6 +1,7 @@ import { formatApplicationEditedAt, formatKSTDate, + formatKSTDateRange, formatKSTDateTime, formatKSTDateTimeFull, } from './formatKSTDateTime'; @@ -97,3 +98,35 @@ describe('formatKSTDateTimeFull', () => { expect(result).toContain('26'); // 날짜 넘어갔는지 확인 }); }); + +describe('formatKSTDateRange', () => { + it('시작과 종료가 같은 날이면 요일까지 붙은 하루 표기를 쓴다', () => { + expect( + formatKSTDateRange( + '2026-11-29T04:00:00+09:00', + '2026-11-29T22:00:00+09:00', + ), + ).toBe('11월 29일 일요일'); + }); + + it('날이 다르면 요일 없이 두 날짜를 보여준다', () => { + expect( + formatKSTDateRange( + '2026-11-29T04:00:00+09:00', + '2026-11-30T02:00:00+09:00', + ), + ).toBe('11월 29일 ~ 11월 30일'); + }); + + it('KST 기준으로 같은 날인지 판단한다 (UTC 기준이면 다른 날이 된다)', () => { + expect( + formatKSTDateRange('2026-11-29T00:30:00+09:00', '2026-11-29T23:30:00+09:00'), + ).toBe('11월 29일 일요일'); + }); + + it('종료가 비면 시작만 보여준다', () => { + expect(formatKSTDateRange('2026-11-29T04:00:00+09:00', '')).toBe( + '11월 29일 일요일', + ); + }); +}); diff --git a/frontend/src/utils/formatKSTDateTime.ts b/frontend/src/utils/formatKSTDateTime.ts index d70a91e30..3257b210b 100644 --- a/frontend/src/utils/formatKSTDateTime.ts +++ b/frontend/src/utils/formatKSTDateTime.ts @@ -29,6 +29,27 @@ export const formatKSTDateTimeFull = (dateStr: string) => minute: '2-digit', }); +const kstDayKey = (dateStr: string) => + formatKSTDateTime(dateStr, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }); + +/** + * 카드처럼 폭이 좁은 곳에서 쓰는 기간 표기. + * 하루짜리면 "11월 29일 일요일", 여러 날이면 요일을 빼고 "11월 29일 ~ 11월 30일". + */ +export const formatKSTDateRange = (startStr: string, endStr: string) => { + if (!startStr) return ''; + if (!endStr || kstDayKey(startStr) === kstDayKey(endStr)) + return formatKSTDate(startStr); + + const short = (dateStr: string) => + formatKSTDateTime(dateStr, { month: 'long', day: 'numeric' }); + return `${short(startStr)} ~ ${short(endStr)}`; +}; + /** "2025. 7. 1 오후 12:46" 형식으로 반환 */ export const formatApplicationEditedAt = (dateStr: string): string => { if (!dateStr) return ''; From d3814c3822a40d86e8535801e85eed38b0738fb2 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 8 Sep 2026 20:42:53 +0900 Subject: [PATCH 17/28] =?UTF-8?q?refactor(admin):=20ApplicationMenu?= =?UTF-8?q?=EB=A5=BC=20AdminMoreMenu=EB=A1=9C=20=EC=98=AE=EA=B8=B0?= =?UTF-8?q?=EA=B3=A0=20=ED=95=AD=EB=AA=A9=EC=9D=84=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=ED=98=95=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홍보 카드도 같은 더보기 메뉴를 쓰는데 항목은 수정·삭제 둘뿐이다. - 핸들러를 넘긴 항목만 그린다. 지원서 3곳은 넷을 모두 넘기므로 결과가 같다 - 고정 높이 138px을 없앤다. 4줄 기준이라 2줄짜리 메뉴에서 아래가 빈다 - 호출부 4곳 중 ApplicationRowItem은 카드가 아니라 행이라 이름에 Card를 넣지 않았다. 트리거가 MoreButton이라 More를 쓴다 --- .../AdminMoreMenu/AdminMoreMenu.stories.tsx | 10 ++-- .../AdminMoreMenu/AdminMoreMenu.styles.ts | 1 - .../AdminMoreMenu/AdminMoreMenu.tsx | 54 +++++++++++-------- .../ApplicationRow/ApplicationRowItem.tsx | 4 +- .../ApplicationCardMobile.tsx | 4 +- .../ApplicationListCardMobile.tsx | 4 +- 6 files changed, 43 insertions(+), 34 deletions(-) diff --git a/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.stories.tsx b/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.stories.tsx index 1021bbd40..4456b6cd6 100644 --- a/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.stories.tsx +++ b/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.stories.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import ApplicationMenu from './ApplicationMenu'; +import AdminMoreMenu from './AdminMoreMenu'; const meta = { - title: 'Pages/AdminPage/components/ApplicationMenu', - component: ApplicationMenu, + title: 'Pages/AdminPage/components/AdminMoreMenu', + component: AdminMoreMenu, parameters: { layout: 'centered' }, tags: ['autodocs'], decorators: [ @@ -25,7 +25,7 @@ const meta = { }, [args.isActive]); return ( - setIsActive((prev) => !prev)} @@ -34,7 +34,7 @@ const meta = { /> ); }, -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; diff --git a/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.styles.ts b/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.styles.ts index 14fbf14ed..1f0860973 100644 --- a/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.styles.ts +++ b/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.styles.ts @@ -7,7 +7,6 @@ export const MenuContainer = styled.div` top: 60%; right: 8px; width: 170px; - height: 138px; background-color: ${colors.base.white}; border-radius: 10px; box-shadow: 0px 1px 8px rgba(0, 0, 0, 0.12); diff --git a/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.tsx b/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.tsx index d5848d419..23d8fbde1 100644 --- a/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.tsx +++ b/frontend/src/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu.tsx @@ -2,48 +2,58 @@ import CheckSquareIcon from '@/assets/images/icons/check_square_icon.svg?react'; import CopyIcon from '@/assets/images/icons/copy_icon.svg'; import Delete_applicant from '@/assets/images/icons/Delete_applicant.svg'; import Pencil from '@/assets/images/icons/pencil_icon_3.svg'; -import * as Styled from './ApplicationMenu.styles'; +import * as Styled from './AdminMoreMenu.styles'; +/** 토글 항목은 지원서에서만 쓴다 (onToggleStatus를 넘긴 화면) */ const TOGGLE_TEXT = { ACTIVE: '지원서 비활성화', INACTIVE: '지원서 활성화', } as const; -interface ApplicationMenuProps { - isActive: boolean; +interface AdminMoreMenuProps { + /** 지원서 활성화 토글용. onToggleStatus를 넘길 때만 쓴다 */ + isActive?: boolean; onDelete: () => void; onToggleStatus?: () => void; onEdit?: () => void; onDuplicate?: () => void; } -const ApplicationMenu = ({ - isActive, +const AdminMoreMenu = ({ + isActive = false, onToggleStatus, onEdit, onDuplicate, onDelete, -}: ApplicationMenuProps) => { +}: AdminMoreMenuProps) => { const toggleText = isActive ? TOGGLE_TEXT.ACTIVE : TOGGLE_TEXT.INACTIVE; return ( - - - - - {toggleText} - - + {onToggleStatus && ( + <> + + + + + {toggleText} + + + + )} - - - 수정하기 - - - - 복제하기 - + {onEdit && ( + + + 수정하기 + + )} + {onDuplicate && ( + + + 복제하기 + + )} 삭제 @@ -53,4 +63,4 @@ const ApplicationMenu = ({ ); }; -export default ApplicationMenu; +export default AdminMoreMenu; diff --git a/frontend/src/pages/AdminPage/components/ApplicationRow/ApplicationRowItem.tsx b/frontend/src/pages/AdminPage/components/ApplicationRow/ApplicationRowItem.tsx index bda741549..83241acb3 100644 --- a/frontend/src/pages/AdminPage/components/ApplicationRow/ApplicationRowItem.tsx +++ b/frontend/src/pages/AdminPage/components/ApplicationRow/ApplicationRowItem.tsx @@ -1,6 +1,6 @@ import type { MouseEvent, RefObject } from 'react'; import Morebutton from '@/assets/images/icons/ellipsis_icon.svg'; -import ApplicationMenu from '@/pages/AdminPage/components/ApplicationMenu/ApplicationMenu'; +import AdminMoreMenu from '@/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu'; import { ApplicationFormItem, ApplicationFormStatus, @@ -62,7 +62,7 @@ const ApplicationRowItem = ({ {isMenuOpen && ( - onEdit(application.id)} onDelete={() => onDelete(application.id)} diff --git a/frontend/src/pages/AdminPage/tabs/ApplicationTab/ApplicationListTab/components/mobile/ApplicationCardMobile/ApplicationCardMobile.tsx b/frontend/src/pages/AdminPage/tabs/ApplicationTab/ApplicationListTab/components/mobile/ApplicationCardMobile/ApplicationCardMobile.tsx index 30c68917b..03f5feab7 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicationTab/ApplicationListTab/components/mobile/ApplicationCardMobile/ApplicationCardMobile.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicationTab/ApplicationListTab/components/mobile/ApplicationCardMobile/ApplicationCardMobile.tsx @@ -1,6 +1,6 @@ import type { MouseEvent, RefObject } from 'react'; import MorebuttonIcon from '@/assets/images/icons/ellipsis_icon.svg?react'; -import ApplicationMenu from '@/pages/AdminPage/components/ApplicationMenu/ApplicationMenu'; +import AdminMoreMenu from '@/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu'; import { ApplicationFormItem, ApplicationFormStatus, @@ -54,7 +54,7 @@ const ApplicationCardMobile = ({ {isMenuOpen && ( - onToggleStatus(application.id, application.status) diff --git a/frontend/src/pages/AdminPage/tabs/ApplicationTab/ApplicationListTab/components/mobile/ApplicationListCardMobile/ApplicationListCardMobile.tsx b/frontend/src/pages/AdminPage/tabs/ApplicationTab/ApplicationListTab/components/mobile/ApplicationListCardMobile/ApplicationListCardMobile.tsx index e44bdb947..83d020da0 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicationTab/ApplicationListTab/components/mobile/ApplicationListCardMobile/ApplicationListCardMobile.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicationTab/ApplicationListTab/components/mobile/ApplicationListCardMobile/ApplicationListCardMobile.tsx @@ -1,6 +1,6 @@ import type { MouseEvent, RefObject } from 'react'; import MorebuttonIcon from '@/assets/images/icons/ellipsis_icon.svg?react'; -import ApplicationMenu from '@/pages/AdminPage/components/ApplicationMenu/ApplicationMenu'; +import AdminMoreMenu from '@/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu'; import { ApplicationFormItem, ApplicationFormStatus, @@ -64,7 +64,7 @@ const ApplicationListCardMobile = ({ {isMenuOpen && ( - onToggleStatus(application.id, application.status) From 932cd0973f385a0f8318ba0a16deddde163d5acb Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 8 Sep 2026 20:43:01 +0900 Subject: [PATCH 18/28] =?UTF-8?q?feat(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EC=99=80=20=EA=B0=99=EC=9D=80=20=EC=B9=B4=EB=93=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=EB=A1=9C=20=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자가 글을 만들자마자 실제 노출 결과를 그대로 보게 한다. - PromotionCardView를 쓰고 우측 상단 AdminMoreMenu로 수정·삭제한다 - 카드 본문을 누르면 수정 화면으로 간다. 카드를 button 안에 넣으면 button이 자식을 잘라 카드 그림자가 사라지므로, 투명 버튼을 위에 덮는다 - 컴팩트 작성 버튼을 MobileFloatingButton으로 바꾼다. bottom은 운영진 문의 버튼(48px, bottom 101px) 위에 오도록 161px로 둔다 --- .../PromotionTab/PromotionListTab.styles.ts | 145 ++---------------- .../PromotionTab/PromotionListTab.test.tsx | 75 ++++++++- .../tabs/PromotionTab/PromotionListTab.tsx | 101 ++++++------ .../AdminPromotionCard.styles.ts | 47 ++++++ .../AdminPromotionCard/AdminPromotionCard.tsx | 57 +++++++ 5 files changed, 239 insertions(+), 186 deletions(-) create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/components/AdminPromotionCard/AdminPromotionCard.styles.ts create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/components/AdminPromotionCard/AdminPromotionCard.tsx diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.styles.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.styles.ts index 8b9116e23..99a8757a3 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.styles.ts @@ -1,4 +1,4 @@ -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; import { media } from '@/styles/mediaQuery'; import { colors } from '@/styles/theme/colors'; import { setTypography, typography } from '@/styles/theme/typography'; @@ -33,9 +33,20 @@ export const CompactBody = styled.div` padding: 16px 20px 40px; `; -export const CompactHeader = styled.div` - display: flex; - justify-content: flex-end; +/* 사용자 홍보 목록과 같은 세로형 카드라 그리드로 깐다. 관리자 본문 폭이 좁아 열 수는 따로 잡는다 */ +export const CardGrid = styled.div` + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 20px; + + ${media.laptop} { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + ${media.tablet} { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } `; export const Notice = styled.div` @@ -70,132 +81,6 @@ export const PlusIcon = styled.img` height: 19px; `; -export const CardList = styled.ul` - display: flex; - flex-direction: column; - gap: 12px; - list-style: none; - padding: 0; - margin: 0; -`; - -export const Card = styled.li` - display: flex; - align-items: center; - gap: 16px; - padding: 14px 16px; - border: 1px solid ${colors.gray[400]}; - border-radius: 20px; - background: ${colors.base.white}; - - ${media.tablet} { - flex-wrap: wrap; - gap: 12px; - padding: 12px; - } -`; - -export const Thumbnail = styled.button` - flex-shrink: 0; - width: 96px; - height: 96px; - padding: 0; - border: none; - border-radius: 12px; - overflow: hidden; - background: ${colors.gray[100]}; - cursor: pointer; - - img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - } - - ${media.tablet} { - width: 72px; - height: 72px; - } -`; - -export const ThumbnailPlaceholder = styled.span` - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - ${setTypography(typography.paragraph.p7)} - color: ${colors.gray[600]}; -`; - -export const CardBody = styled.div` - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; - flex: 1; -`; - -export const CardTitle = styled.p` - ${setTypography(typography.paragraph.p2)} - color: ${colors.gray[900]}; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -export const CardMeta = styled.p` - ${setTypography(typography.paragraph.p6)} - color: ${colors.gray[700]}; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -export const CardActions = styled.div` - display: flex; - gap: 8px; - flex-shrink: 0; - - ${media.tablet} { - width: 100%; - justify-content: flex-end; - } -`; - -export const ActionButton = styled.button<{ $danger?: boolean }>` - height: 34px; - padding: 0 14px; - border: 1px solid ${colors.gray[400]}; - border-radius: 8px; - background: ${colors.base.white}; - ${setTypography(typography.button.button1)} - color: ${colors.gray[800]}; - cursor: pointer; - transition: background-color 0.15s ease; - - &:hover:not(:disabled) { - background: ${colors.gray[100]}; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - ${({ $danger }) => - $danger && - css` - color: #ef4444; - border-color: #fca5a5; - - &:hover:not(:disabled) { - background: #fff1f2; - } - `} -`; - export const EmptyState = styled.div` display: flex; flex-direction: column; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx index 99fa1453c..b5d7d728b 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.test.tsx @@ -18,12 +18,13 @@ jest.mock('@/hooks/Queries/usePromotion', () => ({ })); jest.mock('@/hooks/Mixpanel/useMixpanelTrack', () => () => jest.fn()); jest.mock('@/hooks/Mixpanel/useTrackPageView', () => () => {}); -jest.mock('@/hooks/useDevice', () => () => ({ +const mockDevice = { isMobile: false, isTablet: false, isLaptop: false, isDesktop: true, -})); +}; +jest.mock('@/hooks/useDevice', () => () => mockDevice); const makeArticle = ( overrides: Partial & @@ -41,7 +42,7 @@ const makeArticle = ( ...overrides, }); -const renderTab = (state = 'AVAILABLE') => { +const renderTab = (state = 'AVAILABLE') => render( @@ -50,13 +51,19 @@ const renderTab = (state = 'AVAILABLE') => { element={} > } /> + 수정 화면

} />
, ); -}; beforeEach(() => { + Object.assign(mockDevice, { + isMobile: false, + isTablet: false, + isLaptop: false, + isDesktop: true, + }); mockArticles.length = 0; mockDelete.mockReset(); const root = document.createElement('div'); @@ -80,6 +87,21 @@ describe('PromotionListTab', () => { expect(screen.queryByText('제목 other')).not.toBeInTheDocument(); }); + it('데스크톱은 텍스트가 보이는 작성 버튼을 쓴다', () => { + renderTab(); + expect(screen.getByText('새 게시글 작성')).toBeInTheDocument(); + }); + + it('컴팩트에서는 텍스트 없이 aria-label만 가진 플로팅 버튼을 쓴다', () => { + mockDevice.isMobile = true; + mockDevice.isDesktop = false; + renderTab(); + + const button = screen.getByRole('button', { name: '새 게시글 작성' }); + expect(button).toHaveTextContent(''); + expect(screen.queryByText('새 게시글 작성')).not.toBeInTheDocument(); + }); + it('심사 전 동아리는 작성 버튼 대신 안내 문구를 보여준다', () => { renderTab('UNAVAILABLE'); @@ -93,17 +115,58 @@ describe('PromotionListTab', () => { ).toBeInTheDocument(); }); + it('카드 본문을 누르면 수정 화면으로 간다', () => { + mockArticles.push(makeArticle({ id: 'mine', clubId: 'my-club' })); + renderTab(); + + fireEvent.click(screen.getByRole('button', { name: '제목 mine 수정' })); + + expect(screen.getByText('수정 화면')).toBeInTheDocument(); + }); + + it('수정·삭제는 카드 우측 상단 메뉴를 열어야 나온다', () => { + mockArticles.push(makeArticle({ id: 'mine', clubId: 'my-club' })); + renderTab(); + + expect(screen.queryByText('삭제')).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { name: '제목 mine 관리 메뉴' }), + ); + expect(screen.getByText('수정하기')).toBeInTheDocument(); + expect(screen.getByText('삭제')).toBeInTheDocument(); + }); + + it('메뉴 바깥을 누르면 닫힌다', () => { + mockArticles.push(makeArticle({ id: 'mine', clubId: 'my-club' })); + renderTab(); + + fireEvent.click( + screen.getByRole('button', { name: '제목 mine 관리 메뉴' }), + ); + expect(screen.getByText('삭제')).toBeInTheDocument(); + + fireEvent.mouseDown(document.body); + expect(screen.queryByText('삭제')).not.toBeInTheDocument(); + }); + it('삭제는 확인창을 거친 뒤에만 요청한다', () => { mockArticles.push(makeArticle({ id: 'mine', clubId: 'my-club' })); const confirmSpy = jest.spyOn(window, 'confirm'); renderTab(); + fireEvent.click( + screen.getByRole('button', { name: '제목 mine 관리 메뉴' }), + ); + + // 취소하면 요청도 안 가고 메뉴도 그대로 열려 있다 confirmSpy.mockReturnValueOnce(false); - fireEvent.click(screen.getByRole('button', { name: '삭제' })); + fireEvent.click(screen.getByText('삭제')); expect(mockDelete).not.toHaveBeenCalled(); + expect(screen.getByText('삭제')).toBeInTheDocument(); confirmSpy.mockReturnValueOnce(true); - fireEvent.click(screen.getByRole('button', { name: '삭제' })); + fireEvent.click(screen.getByText('삭제')); expect(mockDelete).toHaveBeenCalledWith('mine', expect.any(Object)); confirmSpy.mockRestore(); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.tsx index 2619de32a..da2ff7be1 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionListTab.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate, useOutletContext } from 'react-router-dom'; import { getServerErrorMessage } from '@/apis/utils/getServerErrorMessage'; +import addLargeIcon from '@/assets/images/icons/add_large_icon.svg'; import Plus from '@/assets/images/icons/Plus.svg'; import Spinner from '@/components/common/Spinner/Spinner'; import Toast from '@/components/common/Toast/Toast'; @@ -14,10 +15,11 @@ import { } from '@/hooks/Queries/usePromotion'; import useDevice from '@/hooks/useDevice'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; +import MobileFloatingButton from '@/pages/AdminPage/components/MobileFloatingButton/MobileFloatingButton'; import { colors } from '@/styles/theme/colors'; import { ClubDetail } from '@/types/club'; import { PromotionArticle } from '@/types/promotion'; -import { formatKSTDateTimeFull } from '@/utils/formatKSTDateTime'; +import AdminPromotionCard from './components/AdminPromotionCard/AdminPromotionCard'; import { isClubApproved, PROMOTION_LIST_PATH, @@ -25,9 +27,6 @@ import { } from './constants'; import * as Styled from './PromotionListTab.styles'; -const formatPeriod = (article: PromotionArticle) => - `${formatKSTDateTimeFull(article.eventStartDate)} ~ ${formatKSTDateTimeFull(article.eventEndDate)}`; - const PromotionListTab = () => { const navigate = useNavigate(); const location = useLocation(); @@ -45,8 +44,7 @@ const PromotionListTab = () => { isError, error, } = useGetPromotionArticles(); - const { mutate: deleteArticle, isPending: isDeleting } = - useDeletePromotionArticle(); + const { mutate: deleteArticle } = useDeletePromotionArticle(); // 작성·수정 화면에서 저장 후 넘어오면서 건넨 문구를 첫 렌더에 띄우고, // 뒤로가기로 돌아왔을 때 다시 뜨지 않도록 history state는 비운다 @@ -60,6 +58,25 @@ const PromotionListTab = () => { navigate(location.pathname, { replace: true, state: null }); }, [incomingToast, location.pathname, navigate]); + const [openMenuId, setOpenMenuId] = useState(null); + const menuRef = useRef(null); + + useEffect(() => { + if (openMenuId === null) return; + const handleOutsideClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpenMenuId(null); + } + }; + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, [openMenuId]); + + const handleMenuToggle = (e: React.MouseEvent, articleId: string) => { + e.stopPropagation(); + setOpenMenuId((prev) => (prev === articleId ? null : articleId)); + }; + const myArticles = (articles ?? []).filter( (article) => article.clubId === clubDetail.id, ); @@ -81,6 +98,7 @@ const PromotionListTab = () => { ) { return; } + setOpenMenuId(null); deleteArticle(article.id, { onSuccess: () => setToastMessage('홍보 게시글이 삭제되었습니다.'), onError: (deleteError) => @@ -113,52 +131,24 @@ const PromotionListTab = () => { } return ( - + {myArticles.map((article) => ( - - handleEdit(article.id)} - > - {article.images[0] ? ( - - ) : ( - - 이미지 없음 - - )} - - - - {article.title} - {article.location} - {formatPeriod(article)} - - - - handleEdit(article.id)} - > - 수정 - - handleDelete(article)} - > - 삭제 - - - + ))} - + ); }; - const createButton = isApproved && ( + // 컴팩트에서는 다른 관리자 화면과 같은 주황색 + 플로팅 버튼을 쓴다 + const desktopCreateButton = isApproved && ( 새 게시글 작성 @@ -178,13 +168,24 @@ const PromotionListTab = () => { {PROMOTION_NOT_APPROVED_MESSAGE} )} - {createButton} {renderBody()} + {isApproved && ( + + )} ) : ( - + {!isApproved && myArticles.length > 0 && ( diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/AdminPromotionCard/AdminPromotionCard.styles.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/AdminPromotionCard/AdminPromotionCard.styles.ts new file mode 100644 index 000000000..b33da4ea9 --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/AdminPromotionCard/AdminPromotionCard.styles.ts @@ -0,0 +1,47 @@ +import styled from 'styled-components'; +import { colors } from '@/styles/theme/colors'; + +export const Wrapper = styled.div` + position: relative; +`; + +/* 카드 본문 전체가 수정 화면으로 가는 버튼이다 */ +/* + * 카드를 button 안에 넣으면 button이 자식을 자기 박스로 잘라내 + * 카드 그림자(밝은 배경에서 모서리를 보이게 하는 유일한 요소)가 사라진다. + * 그래서 카드 위에 투명 버튼을 덮는다. border-radius는 포커스 링을 카드 모양에 맞추려고 준다. + */ +export const CardOverlayButton = styled.button` + position: absolute; + inset: 0; + padding: 0; + border: none; + border-radius: 14px; + background: none; + cursor: pointer; +`; + +/* 카드의 overflow: hidden 밖에 있어야 메뉴가 잘리지 않는다 */ +export const MenuContainer = styled.div` + position: absolute; + top: 8px; + right: 8px; +`; + +export const MoreButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: none; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.9); + cursor: pointer; + + &:hover { + background-color: ${colors.base.white}; + } +`; + diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/AdminPromotionCard/AdminPromotionCard.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/AdminPromotionCard/AdminPromotionCard.tsx new file mode 100644 index 000000000..1d4fa438c --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/AdminPromotionCard/AdminPromotionCard.tsx @@ -0,0 +1,57 @@ +import MoreButtonIcon from '@/assets/images/icons/ellipsis_icon.svg?react'; +import PromotionCardView from '@/components/promotion/PromotionCardView/PromotionCardView'; +import AdminMoreMenu from '@/pages/AdminPage/components/AdminMoreMenu/AdminMoreMenu'; +import { PromotionArticle } from '@/types/promotion'; +import * as Styled from './AdminPromotionCard.styles'; + +interface AdminPromotionCardProps { + article: PromotionArticle; + isMenuOpen: boolean; + /** 열린 메뉴에만 붙여 바깥 클릭 감지에 쓴다 */ + menuRef: React.RefObject; + onMenuToggle: (e: React.MouseEvent, articleId: string) => void; + onEdit: (articleId: string) => void; + onDelete: (article: PromotionArticle) => void; +} + +const AdminPromotionCard = ({ + article, + isMenuOpen, + menuRef, + onMenuToggle, + onEdit, + onDelete, +}: AdminPromotionCardProps) => { + return ( + + + + onEdit(article.id)} + /> + + {/* 오버레이 버튼보다 뒤에 둬야 클릭이 메뉴로 간다 */} + + onMenuToggle(e, article.id)} + > + + + + {isMenuOpen && ( + onEdit(article.id)} + onDelete={() => onDelete(article)} + /> + )} + + + ); +}; + +export default AdminPromotionCard; From 7c78db35896e0f4314c221a8ffe706bac5c3dd1f Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 8 Sep 2026 20:43:12 +0900 Subject: [PATCH 19/28] =?UTF-8?q?feat(map):=20=EC=A2=8C=ED=91=9C=EA=B0=80?= =?UTF-8?q?=20=EC=A0=95=ED=95=B4=EC=A7=80=EA=B8=B0=20=EC=A0=84=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=EC=A7=80=EB=8F=84=EB=A5=BC=20=EB=B3=B4=EC=97=AC?= =?UTF-8?q?=EC=A4=84=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=EB=A7=88=EC=BB=A4?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=EC=98=B5=EC=85=98=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit showMarker 기본값이 true라 기존 사용처 동작은 그대로다. 의존성 배열에도 넣어야 선택 직후 마커가 나타난다. --- .../src/components/map/NaverMap/NaverMap.tsx | 8 +++-- frontend/src/hooks/Map/useNaverMap.ts | 32 +++++++++++-------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/map/NaverMap/NaverMap.tsx b/frontend/src/components/map/NaverMap/NaverMap.tsx index d6b06cafa..e50002f99 100644 --- a/frontend/src/components/map/NaverMap/NaverMap.tsx +++ b/frontend/src/components/map/NaverMap/NaverMap.tsx @@ -4,12 +4,16 @@ import * as Styled from './NaverMap.styles'; interface NaverMapProps { location: { lat: number; lng: number }; + showMarker?: boolean; } -const NaverMap = ({ location }: NaverMapProps) => { +const NaverMap = ({ location, showMarker = true }: NaverMapProps) => { const mapRef = useRef(null); - useNaverMap(mapRef, location.lat, location.lng, { interactive: false }); + useNaverMap(mapRef, location.lat, location.lng, { + interactive: false, + showMarker, + }); return ; }; diff --git a/frontend/src/hooks/Map/useNaverMap.ts b/frontend/src/hooks/Map/useNaverMap.ts index 2fa28c6ef..a89ec92e8 100644 --- a/frontend/src/hooks/Map/useNaverMap.ts +++ b/frontend/src/hooks/Map/useNaverMap.ts @@ -8,6 +8,8 @@ interface UseNaverMapOptions { active?: boolean; interactive?: boolean; markerSize?: number; + /** 마커를 찍을지. 좌표가 아직 확정되지 않은 화면에서 지도만 먼저 보여줄 때 false */ + showMarker?: boolean; bubbleText?: string; bubbleFontSize?: number; bubbleFontWeight?: number; @@ -70,6 +72,7 @@ export const useNaverMap = ( active = true, interactive = true, markerSize = 40, + showMarker = true, bubbleText, bubbleFontSize, bubbleFontWeight, @@ -105,19 +108,21 @@ export const useNaverMap = ( externalRef.current = mapInstance; } - new naver.maps.Marker({ - position, - map: mapInstance, - icon: { - content: buildMarkerContent( - markerSize, - bubbleText, - bubbleFontSize, - bubbleFontWeight, - ), - anchor: new naver.maps.Point(markerSize / 2, markerSize), - }, - }); + if (showMarker) { + new naver.maps.Marker({ + position, + map: mapInstance, + icon: { + content: buildMarkerContent( + markerSize, + bubbleText, + bubbleFontSize, + bubbleFontWeight, + ), + anchor: new naver.maps.Point(markerSize / 2, markerSize), + }, + }); + } }); return () => { @@ -136,6 +141,7 @@ export const useNaverMap = ( active, interactive, markerSize, + showMarker, bubbleText, bubbleFontSize, bubbleFontWeight, From ea0fd40991e483a9bcafb1731b6caee2c5410235 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 8 Sep 2026 20:43:12 +0900 Subject: [PATCH 20/28] =?UTF-8?q?fix(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=ED=99=94=EB=A9=B4=EC=9D=98=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=C2=B7=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=C2=B7?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=EB=A5=BC=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰에서 지적된 세 가지를 함께 고친다. - 지도 위치 필드인데 건물을 고르기 전에는 지도가 아예 없었다. 항상 캠퍼스 기준 좌표로 지도를 띄우고, 선택 전에는 마커를 찍지 않는다 - 드롭다운을 지원자 현황의 지원서 선택 트리거와 같은 모양으로 맞춘다. 네이티브 select는 유지한다. 모바일 웹뷰에서 OS 기본 피커가 낫다 - 작성 중 일부 이미지 업로드가 실패하면 수정 화면으로 넘기며 문구를 함께 보내는데 받는 쪽이 location.state를 읽지 않아 사라지고 있었다. /new에서 /:id/edit은 같은 컴포넌트라 리마운트되지 않을 수 있어 렌더 중에 받는다 --- .../PromotionTab/PromotionEditTab.styles.ts | 38 ++++++-- .../PromotionTab/PromotionEditTab.test.tsx | 97 +++++++++++++++++++ .../tabs/PromotionTab/PromotionEditTab.tsx | 90 +++++++++++------ 3 files changed, 186 insertions(+), 39 deletions(-) create mode 100644 frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.test.tsx diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.styles.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.styles.ts index 7745cfcb3..93083eec8 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.styles.ts @@ -1,4 +1,5 @@ import styled from 'styled-components'; +import MoreArrowIcon from '@/assets/images/icons/more_arraw_icon.svg?react'; import { media } from '@/styles/mediaQuery'; import { colors } from '@/styles/theme/colors'; import { setTypography, typography } from '@/styles/theme/typography'; @@ -77,20 +78,30 @@ export const HelperText = styled.p` color: ${colors.gray[600]}; `; +/* 지원자 현황 탭의 지원서 선택 드롭다운(FormDropdownSelector)과 같은 모양 */ +export const SelectWrapper = styled.div` + position: relative; +`; + export const Select = styled.select` width: 100%; - height: 45px; - padding: 0 18px; - border: 1px solid ${colors.gray[500]}; - border-radius: 6px; - background-color: transparent; - font-size: 1.125rem; - color: rgba(0, 0, 0, 0.8); + height: 52px; + padding: 14px 44px 14px 18px; + border: 1px solid ${colors.gray[200]}; + border-radius: 14px; + background-color: ${colors.gray[50]}; + ${setTypography(typography.paragraph.p2)} + color: ${colors.base.black}; cursor: pointer; + box-sizing: border-box; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; &:focus { outline: none; - box-shadow: 0 0 3px; + border-color: ${colors.primary[800]}; + background-color: ${colors.base.white}; } &:disabled { @@ -103,6 +114,17 @@ export const Select = styled.select` } `; +export const SelectChevron = styled(MoreArrowIcon)` + position: absolute; + top: 50%; + right: 18px; + transform: translateY(-50%); + width: 16px; + height: 16px; + color: ${colors.gray[500]}; + pointer-events: none; +`; + export const MapPreview = styled.div` margin-top: 12px; width: 100%; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.test.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.test.tsx new file mode 100644 index 000000000..2fdd760de --- /dev/null +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.test.tsx @@ -0,0 +1,97 @@ +import '@testing-library/jest-dom'; +import { MemoryRouter, Outlet, Route, Routes } from 'react-router-dom'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from 'styled-components'; +import { theme } from '@/styles/theme'; +import { PromotionArticle } from '@/types/promotion'; +import PromotionEditTab from './PromotionEditTab'; + +const mockArticles: PromotionArticle[] = []; +jest.mock('@/hooks/Queries/usePromotion', () => ({ + useGetPromotionArticles: () => ({ + data: mockArticles, + isLoading: false, + isError: false, + error: null, + }), + useCreatePromotionArticle: () => ({ mutateAsync: jest.fn() }), + useUpdatePromotionArticle: () => ({ mutateAsync: jest.fn() }), + useUploadPromotionImages: () => ({ mutateAsync: jest.fn() }), +})); +jest.mock('@/hooks/Mixpanel/useMixpanelTrack', () => () => jest.fn()); +jest.mock('@/hooks/Mixpanel/useTrackPageView', () => () => {}); +jest.mock('@/hooks/useDevice', () => () => ({ + isMobile: false, + isTablet: false, + isLaptop: false, + isDesktop: true, +})); +jest.mock('@/components/map/NaverMap/NaverMap', () => () =>
); +// react-datepicker의 css import를 jest가 파싱하지 못해 통째로 대체한다 +jest.mock( + '@/pages/AdminPage/tabs/RecruitEditTab/components/DateTimeRangePicker/DateTimeRangePicker', + () => () =>
, +); + +const article: PromotionArticle = { + id: 'a1', + clubId: 'my-club', + clubName: '동아리', + title: '봄 정기공연', + location: '한울관(E31)', + latitude: 35.13, + longitude: 129.1, + eventStartDate: '2026-04-01T01:00:00Z', + eventEndDate: '2026-04-01T03:00:00Z', + description: '설명', + images: ['https://cdn/a.png'], +}; + +const renderEditTab = (state: unknown) => + render( + + + + } + > + } + /> + + + + , + ); + +beforeEach(() => { + mockArticles.length = 0; + mockArticles.push(article); + const root = document.createElement('div'); + root.id = 'modal-root'; + document.body.appendChild(root); +}); + +afterEach(() => { + document.getElementById('modal-root')?.remove(); +}); + +describe('PromotionEditTab 넘겨받은 토스트', () => { + it('작성 중 이미지 업로드가 일부 실패해 넘어온 문구를 띄운다', () => { + const message = '글은 저장됐지만 이미지 2장 업로드에 실패했어요. 다시 올려주세요.'; + + renderEditTab({ toastMessage: message }); + + expect(screen.getByText(message)).toBeInTheDocument(); + }); + + it('넘어온 문구가 없으면 토스트를 띄우지 않는다', () => { + renderEditTab(null); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx index 817a51bf4..33907cd7f 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx @@ -1,5 +1,10 @@ -import { useState } from 'react'; -import { useNavigate, useOutletContext, useParams } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { + useLocation, + useNavigate, + useOutletContext, + useParams, +} from 'react-router-dom'; import Button from '@/components/common/Button/Button'; import CustomTextArea from '@/components/common/CustomTextArea/CustomTextArea'; import FixedBottomButtonArea from '@/components/common/FixedBottomButtonArea/FixedBottomButtonArea'; @@ -39,9 +44,13 @@ import { const CUSTOM_BUILDING_VALUE = '__custom__'; +/** 건물을 고르기 전에도 지도를 보여주기 위한 기준 좌표. 마커는 찍지 않는다 */ +const DEFAULT_MAP_CENTER = BUILDING_OPTIONS[0].coordinates; + const PromotionEditTab = () => { const { articleId } = useParams<{ articleId: string }>(); const navigate = useNavigate(); + const location = useLocation(); const trackEvent = useMixpanelTrack(); const { isMobile, isTablet } = useDevice(); const isCompact = isMobile || isTablet; @@ -66,6 +75,21 @@ const PromotionEditTab = () => { const { values, setField } = form; const [toastMessage, setToastMessage] = useState(null); + // 작성 중 일부 이미지 업로드가 실패하면 수정 화면으로 replace하며 문구를 함께 넘긴다. + // 여기서 읽지 않으면 "이미지가 안 올라갔다"는 사실이 사용자에게 전혀 안 보인다. + // /new → /:id/edit은 같은 컴포넌트라 다시 마운트되지 않을 수 있어 렌더 중에 받는다. + const incomingToast = (location.state as { toastMessage?: string } | null) + ?.toastMessage; + const [consumedToast, setConsumedToast] = useState(null); + if (incomingToast && incomingToast !== consumedToast) { + setConsumedToast(incomingToast); + setToastMessage(incomingToast); + } + useEffect(() => { + if (!incomingToast) return; + navigate(location.pathname, { replace: true, state: null }); + }, [incomingToast, location.pathname, navigate]); + const isEdit = Boolean(articleId); const isFormDisabled = !isApproved || form.isSaving; const selectedBuilding = findBuildingByCoordinates(values.coordinates); @@ -182,34 +206,38 @@ const PromotionEditTab = () => {
지도 위치 - - - {buildingSelectValue === CUSTOM_BUILDING_VALUE && ( - - )} - {BUILDING_OPTIONS.map((option) => ( - + + 선택한 건물 위치가 홍보글 상세의 지도에 표시돼요. - {values.coordinates && ( - - - - )} + + +
{ /> From 30bfa2629c0cf45869a6f8e8beecf2d601a98b05 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 8 Sep 2026 20:43:16 +0900 Subject: [PATCH 21/28] =?UTF-8?q?style(promotion):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=9D=BC=EC=8B=9C=20=EA=B5=AC=EB=B6=84?= =?UTF-8?q?=EC=9E=90=EB=A5=BC=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EB=AC=BC=EA=B2=B0?= =?UTF-8?q?=ED=91=9C=EB=A1=9C=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detail/PromotionInfoSection/PromotionInfoSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/PromotionPage/components/detail/PromotionInfoSection/PromotionInfoSection.tsx b/frontend/src/pages/PromotionPage/components/detail/PromotionInfoSection/PromotionInfoSection.tsx index 5d226c67b..58636705e 100644 --- a/frontend/src/pages/PromotionPage/components/detail/PromotionInfoSection/PromotionInfoSection.tsx +++ b/frontend/src/pages/PromotionPage/components/detail/PromotionInfoSection/PromotionInfoSection.tsx @@ -15,7 +15,7 @@ const PromotionInfoSection = ({ article }: Props) => { 📅 일시 - {formatKSTDateTimeFull(article.eventStartDate)} -{' '} + {formatKSTDateTimeFull(article.eventStartDate)} ~{' '} {formatKSTDateTimeFull(article.eventEndDate)} From 8f86ee3434afcaea565c1209483d6022a0ea8bd6 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 8 Sep 2026 21:01:49 +0900 Subject: [PATCH 22/28] =?UTF-8?q?refactor(admin):=20=EC=B5=9C=EC=A2=85=20U?= =?UTF-8?q?RL=20=EC=A1=B0=EB=A6=BD=20=EC=9C=A0=ED=8B=B8=EC=9D=84=20?= =?UTF-8?q?=ED=99=8D=EB=B3=B4=C2=B7=ED=99=9C=EB=8F=99=20=EC=82=AC=EC=A7=84?= =?UTF-8?q?=EC=9D=B4=20=ED=95=A8=EA=BB=98=20=EC=93=B0=EA=B2=8C=20=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2026이 활동 사진에 넣은 buildFinalUrls와 홍보 폼의 orderedUrls가 같은 불변식(결과는 화면 순서의 부분 수열)을 각자 구현하고 있었다. reorderItems가 이미 있는 components/ImageSortGrid로 옮겨 한 벌만 남긴다. --- .../ImageSortGrid/buildFinalUrls.test.ts | 42 +++++++++++++++++++ .../ImageSortGrid/buildFinalUrls.ts | 17 ++++++++ .../tabs/PhotoEditTab/hooks/useFeedItems.ts | 2 +- .../tabs/PhotoEditTab/photoEditUtils.test.ts | 33 --------------- .../tabs/PhotoEditTab/photoEditUtils.ts | 16 ------- .../PromotionTab/hooks/usePromotionForm.ts | 17 +++----- 6 files changed, 66 insertions(+), 61 deletions(-) create mode 100644 frontend/src/pages/AdminPage/components/ImageSortGrid/buildFinalUrls.test.ts create mode 100644 frontend/src/pages/AdminPage/components/ImageSortGrid/buildFinalUrls.ts diff --git a/frontend/src/pages/AdminPage/components/ImageSortGrid/buildFinalUrls.test.ts b/frontend/src/pages/AdminPage/components/ImageSortGrid/buildFinalUrls.test.ts new file mode 100644 index 000000000..c9cb16bbc --- /dev/null +++ b/frontend/src/pages/AdminPage/components/ImageSortGrid/buildFinalUrls.test.ts @@ -0,0 +1,42 @@ +import { buildFinalUrls } from './buildFinalUrls'; +import { ImageItem, LocalItem } from './types'; + +const makeUploaded = (url: string): ImageItem => ({ type: 'uploaded', url }); +const makeLocal = (name: string): LocalItem => ({ + type: 'local', + file: new File([''], name, { type: 'image/jpeg' }), + previewUrl: `blob:${name}`, + status: 'pending', +}); + +describe('buildFinalUrls', () => { + it('새로 올린 사진이 앞에 있어도 화면 순서를 그대로 유지한다', () => { + const local = makeLocal('new.jpg'); + const items: ImageItem[] = [local, makeUploaded('b'), makeUploaded('c')]; + const urlByFile = new Map([[local.file, 'a']]); + expect(buildFinalUrls(items, urlByFile)).toEqual(['a', 'b', 'c']); + }); + + it('새로 올린 사진이 중간에 있어도 화면 순서를 그대로 유지한다', () => { + const local = makeLocal('new.jpg'); + const items: ImageItem[] = [makeUploaded('a'), local, makeUploaded('c')]; + const urlByFile = new Map([[local.file, 'b']]); + expect(buildFinalUrls(items, urlByFile)).toEqual(['a', 'b', 'c']); + }); + + it('업로드에 실패해 URL이 없는 local 아이템은 제외한다', () => { + const uploaded = makeLocal('ok.jpg'); + const failed = makeLocal('fail.jpg'); + const items: ImageItem[] = [uploaded, makeUploaded('b'), failed]; + const urlByFile = new Map([[uploaded.file, 'a']]); + expect(buildFinalUrls(items, urlByFile)).toEqual(['a', 'b']); + }); + + it('local 아이템이 없으면 uploaded URL을 순서대로 반환한다', () => { + const items: ImageItem[] = [makeUploaded('b'), makeUploaded('a')]; + expect(buildFinalUrls(items, new Map())).toEqual([ + 'b', + 'a', + ]); + }); +}); diff --git a/frontend/src/pages/AdminPage/components/ImageSortGrid/buildFinalUrls.ts b/frontend/src/pages/AdminPage/components/ImageSortGrid/buildFinalUrls.ts new file mode 100644 index 000000000..4f99c1340 --- /dev/null +++ b/frontend/src/pages/AdminPage/components/ImageSortGrid/buildFinalUrls.ts @@ -0,0 +1,17 @@ +import { ImageItem } from './types'; + +// 화면 순서(items)를 그대로 보존한 최종 URL 배열을 만든다. +// 업로드되지 않아 URL이 없는 local 아이템은 제외된다. +export const buildFinalUrls = ( + items: ImageItem[], + urlByFile: Map, +): string[] => + items.reduce((acc, item) => { + if (item.type === 'uploaded') { + acc.push(item.url); + return acc; + } + const url = urlByFile.get(item.file); + if (url) acc.push(url); + return acc; + }, []); diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts index c7e958ff5..76ad5c551 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts @@ -1,12 +1,12 @@ import { useEffect, useRef, useState } from 'react'; import { useUpdateFeed, useUploadFeed } from '@/hooks/Queries/useClubImages'; import { - buildFinalUrls, extractLocalItems, findOversizedFile, hasPendingChanges, sliceToLimit, } from '../photoEditUtils'; +import { buildFinalUrls } from '@/pages/AdminPage/components/ImageSortGrid/buildFinalUrls'; import { ImageItem, LocalItem, diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts index 7cf966cb8..331baed95 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.test.ts @@ -4,7 +4,6 @@ import { LocalItem, } from '@/pages/AdminPage/components/ImageSortGrid/types'; import { - buildFinalUrls, findOversizedFile, hasPendingChanges, sliceToLimit, @@ -88,35 +87,3 @@ describe('hasPendingChanges', () => { expect(hasPendingChanges([], [])).toBe(false); }); }); - -describe('buildFinalUrls', () => { - it('새로 올린 사진이 앞에 있어도 화면 순서를 그대로 유지한다', () => { - const local = makeLocal('new.jpg'); - const feedItems: ImageItem[] = [local, makeUploaded('b'), makeUploaded('c')]; - const urlByFile = new Map([[local.file, 'a']]); - expect(buildFinalUrls(feedItems, urlByFile)).toEqual(['a', 'b', 'c']); - }); - - it('새로 올린 사진이 중간에 있어도 화면 순서를 그대로 유지한다', () => { - const local = makeLocal('new.jpg'); - const feedItems: ImageItem[] = [makeUploaded('a'), local, makeUploaded('c')]; - const urlByFile = new Map([[local.file, 'b']]); - expect(buildFinalUrls(feedItems, urlByFile)).toEqual(['a', 'b', 'c']); - }); - - it('업로드에 실패해 URL이 없는 local 아이템은 제외한다', () => { - const uploaded = makeLocal('ok.jpg'); - const failed = makeLocal('fail.jpg'); - const feedItems: ImageItem[] = [uploaded, makeUploaded('b'), failed]; - const urlByFile = new Map([[uploaded.file, 'a']]); - expect(buildFinalUrls(feedItems, urlByFile)).toEqual(['a', 'b']); - }); - - it('local 아이템이 없으면 uploaded URL을 순서대로 반환한다', () => { - const feedItems: ImageItem[] = [makeUploaded('b'), makeUploaded('a')]; - expect(buildFinalUrls(feedItems, new Map())).toEqual([ - 'b', - 'a', - ]); - }); -}); diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts index 28f7291ec..3a322d2a8 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/photoEditUtils.ts @@ -26,19 +26,3 @@ export const hasPendingChanges = ( export const extractLocalItems = (feedItems: ImageItem[]): LocalItem[] => feedItems.filter((item): item is LocalItem => item.type === 'local'); - -// 화면 순서(feedItems)를 그대로 보존한 최종 URL 배열을 만든다. -// 업로드되지 않아 URL이 없는 local 아이템은 제외된다. -export const buildFinalUrls = ( - feedItems: ImageItem[], - urlByFile: Map, -): string[] => - feedItems.reduce((acc, item) => { - if (item.type === 'uploaded') { - acc.push(item.url); - return acc; - } - const url = urlByFile.get(item.file); - if (url) acc.push(url); - return acc; - }, []); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts index ad20174bd..62b647bb4 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts @@ -5,6 +5,7 @@ import { useUpdatePromotionArticle, useUploadPromotionImages, } from '@/hooks/Queries/usePromotion'; +import { buildFinalUrls } from '@/pages/AdminPage/components/ImageSortGrid/buildFinalUrls'; import { ImageItem, LocalItem, @@ -102,10 +103,7 @@ export const usePromotionForm = ({ const reorderImages = (images: ImageItem[]) => setValues((prev) => ({ ...prev, images })); - /** - * 아직 안 올린 파일만 업로드하고, 화면 순서를 유지한 채 URL 목록을 만든다. - * 활동 사진처럼 "기존 → 새 것"으로 다시 세우지 않는 이유가 이것이다. - */ + /** 아직 안 올린 파일만 업로드하고, 화면 순서를 유지한 채 URL 목록을 만든다 */ const uploadFiles = async (articleId: string) => { const localFiles = values.images .filter((item): item is LocalItem => item.type === 'local') @@ -129,13 +127,10 @@ export const usePromotionForm = ({ }), })); - const orderedUrls = values.images - .map((item) => - item.type === 'uploaded' ? item.url : urlByFile.get(item.file), - ) - .filter((url): url is string => Boolean(url)); - - return { orderedUrls, failedCount: failedFiles.length }; + return { + orderedUrls: buildFinalUrls(values.images, urlByFile), + failedCount: failedFiles.length, + }; }; const save = async (): Promise => { From 11da163db4d9761a9bc2b42e7eac1a4244bc4b55 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 8 Sep 2026 21:07:38 +0900 Subject: [PATCH 23/28] =?UTF-8?q?fix(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=9C=84=EC=B9=98=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B9=A0=EC=A7=80=EB=8D=98=20=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=EB=A5=BC=20=EC=82=B4=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUILDING_OPTIONS가 건물명으로 dedupe해서, 한 건물에 좌표가 둘인 한솔관(E16)의 뒤쪽 좌표(B동, 동아리 14곳)가 목록에 아예 없었다. 한솔관을 고르면 좌표가 무엇이든 A동으로 찍혔고, B동 좌표로 저장된 글은 '직접 지정된 위치'로 표시됐다. - 좌표 기준으로 묶어 관리 중인 위치 5곳을 모두 노출한다 - 건물명이 겹치는 좌표는 동아리방 표기의 동으로 구분한다 (한솔관(E16) A동/B동) - 좌표 누락과 value 중복을 테스트로 막는다. clubLocations가 바뀌어 구분이 깨지면 실패한다 --- .../PromotionTab/utils/promotionForm.test.ts | 37 ++++++++++++++---- .../tabs/PromotionTab/utils/promotionForm.ts | 39 ++++++++++++++----- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts index f8872aa0c..75c94d217 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts @@ -1,3 +1,4 @@ +import { clubLocations } from '@/constants/clubLocation'; import { ImageItem } from '@/pages/AdminPage/components/ImageSortGrid/types'; import { PromotionArticle } from '@/types/promotion'; import { @@ -35,14 +36,34 @@ const makeUploadedImage = (url: string): ImageItem => ({ }); describe('BUILDING_OPTIONS', () => { - it('건물명 기준으로 중복 없이 좌표를 갖는다', () => { - const names = BUILDING_OPTIONS.map((o) => o.value); - expect(new Set(names).size).toBe(names.length); - expect(BUILDING_OPTIONS.length).toBeGreaterThan(0); - BUILDING_OPTIONS.forEach((o) => { - expect(typeof o.coordinates.lat).toBe('number'); - expect(typeof o.coordinates.lng).toBe('number'); - }); + const uniqueCoordinates = new Set( + clubLocations.map(({ lat, lng }) => `${lat},${lng}`), + ); + + it('관리 중인 좌표를 하나도 빠뜨리지 않는다', () => { + // 건물명으로 묶으면 한솔관(E16)처럼 좌표가 둘인 건물의 뒤쪽이 사라진다 + expect(BUILDING_OPTIONS.length).toBe(uniqueCoordinates.size); + expect( + new Set( + BUILDING_OPTIONS.map((o) => `${o.coordinates.lat},${o.coordinates.lng}`), + ), + ).toEqual(uniqueCoordinates); + }); + + it('value가 겹치지 않는다', () => { + // 겹치면 select에서 좌표가 다른 두 위치를 구분할 수 없다 + const values = BUILDING_OPTIONS.map((o) => o.value); + expect(new Set(values).size).toBe(values.length); + }); + + it('같은 건물에 좌표가 여럿이면 동으로 구분한다', () => { + const hansol = BUILDING_OPTIONS.filter((o) => + o.label.startsWith('한솔관(E16)'), + ); + expect(hansol.map((o) => o.label).sort()).toEqual([ + '한솔관(E16) A동', + '한솔관(E16) B동', + ]); }); it('좌표로 건물을 되찾을 수 있고 없는 좌표면 undefined', () => { diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts index cea18c004..2d0a8f83e 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.ts @@ -37,17 +37,38 @@ export interface BuildingOption { coordinates: Coordinates; } +/** 동아리방 표기 맨 앞의 동 (예: 'A동 208호' → 'A동') */ +const DONG_PREFIX = /^[A-Za-z]동/; + /** - * 관리자가 위도·경도를 직접 입력하지 않도록 캠퍼스 건물 목록에서 고른다. - * 같은 건물이 여러 동아리에 걸쳐 있으니 건물명 기준으로 한 번만 남긴다. + * 관리자가 위도·경도를 직접 입력하지 않도록 실제로 관리 중인 위치 목록에서 고른다. + * 건물명이 아니라 좌표 기준으로 묶는다. 한솔관(E16)처럼 한 건물에 좌표가 둘인 곳이 + * 있어서 건물명으로 묶으면 뒤쪽 좌표가 통째로 사라진다. + * 건물명이 겹치는 좌표끼리는 동아리방 표기의 동으로 구분한다. */ -export const BUILDING_OPTIONS: BuildingOption[] = clubLocations.reduce< - BuildingOption[] ->((options, { building, lat, lng }) => { - if (options.some((option) => option.value === building)) return options; - options.push({ label: building, value: building, coordinates: { lat, lng } }); - return options; -}, []); +export const BUILDING_OPTIONS: BuildingOption[] = (() => { + const byCoordinates = new Map(); + clubLocations.forEach((location) => { + const key = `${location.lat},${location.lng}`; + if (!byCoordinates.has(key)) byCoordinates.set(key, location); + }); + + const locations = [...byCoordinates.values()]; + const buildingCount = locations.reduce>( + (counts, { building }) => ({ + ...counts, + [building]: (counts[building] ?? 0) + 1, + }), + {}, + ); + + return locations.map(({ building, detailLocation, lat, lng }) => { + const dong = detailLocation.match(DONG_PREFIX)?.[0]; + const label = + buildingCount[building] > 1 && dong ? `${building} ${dong}` : building; + return { label, value: label, coordinates: { lat, lng } }; + }); +})(); export const findBuildingByCoordinates = ( coordinates: Coordinates | null, From 1369d726b661a2f28db4f9dab5608750d034e4df Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Wed, 9 Sep 2026 17:05:01 +0900 Subject: [PATCH 24/28] =?UTF-8?q?fix(promotion):=20=EC=A2=85=EB=A3=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=B4=20=EC=9E=98=EB=AA=BB=EB=90=9C=20=EA=B0=92?= =?UTF-8?q?=EC=9D=B4=EB=A9=B4=20=EA=B8=B0=EA=B0=84=20=ED=91=9C=EA=B8=B0?= =?UTF-8?q?=EA=B0=80=20=EB=AC=BC=EA=B2=B0=ED=91=9C=EB=A1=9C=20=EB=81=9D?= =?UTF-8?q?=EB=82=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatKSTDateTime은 파싱 실패 시 빈 문자열을 돌려주는데, formatKSTDateRange는 endStr이 비었는지만 보고 있었다. 값이 있지만 파싱되지 않는 경우 두 날의 키가 서로 달라져 여러 날 분기로 들어가고 "11월 29일 ~ "로 끝났다. 종료일 키가 비면 하루짜리로 본다. 함께 Prettier 형식을 맞춰 CI를 되살린다. --- .../ImageSortGrid/ImageSortGrid.stories.tsx | 2 +- .../components/ImageSortGrid/buildFinalUrls.test.ts | 5 +---- .../components/ImageSortGrid/reorderItems.test.ts | 2 +- .../tabs/PhotoEditTab/PhotoEditTabDesktop.tsx | 2 +- .../tabs/PhotoEditTab/PhotoEditTabMobile.tsx | 4 ++-- .../tabs/PhotoEditTab/hooks/useFeedItems.ts | 12 ++++++------ .../tabs/PromotionTab/PromotionEditTab.test.tsx | 3 ++- .../AdminPromotionCard/AdminPromotionCard.styles.ts | 1 - .../tabs/PromotionTab/utils/promotionForm.test.ts | 4 +++- frontend/src/utils/formatKSTDateTime.test.ts | 11 ++++++++++- frontend/src/utils/formatKSTDateTime.ts | 5 +++-- 11 files changed, 30 insertions(+), 21 deletions(-) diff --git a/frontend/src/pages/AdminPage/components/ImageSortGrid/ImageSortGrid.stories.tsx b/frontend/src/pages/AdminPage/components/ImageSortGrid/ImageSortGrid.stories.tsx index d8cdd9a3f..b4b2058a2 100644 --- a/frontend/src/pages/AdminPage/components/ImageSortGrid/ImageSortGrid.stories.tsx +++ b/frontend/src/pages/AdminPage/components/ImageSortGrid/ImageSortGrid.stories.tsx @@ -1,7 +1,7 @@ import { useRef } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import type { ImageItem } from './types'; import { ImageSortGrid } from './ImageSortGrid'; +import type { ImageItem } from './types'; const img = (seed: string): ImageItem => ({ type: 'uploaded', diff --git a/frontend/src/pages/AdminPage/components/ImageSortGrid/buildFinalUrls.test.ts b/frontend/src/pages/AdminPage/components/ImageSortGrid/buildFinalUrls.test.ts index c9cb16bbc..bcd6d2601 100644 --- a/frontend/src/pages/AdminPage/components/ImageSortGrid/buildFinalUrls.test.ts +++ b/frontend/src/pages/AdminPage/components/ImageSortGrid/buildFinalUrls.test.ts @@ -34,9 +34,6 @@ describe('buildFinalUrls', () => { it('local 아이템이 없으면 uploaded URL을 순서대로 반환한다', () => { const items: ImageItem[] = [makeUploaded('b'), makeUploaded('a')]; - expect(buildFinalUrls(items, new Map())).toEqual([ - 'b', - 'a', - ]); + expect(buildFinalUrls(items, new Map())).toEqual(['b', 'a']); }); }); diff --git a/frontend/src/pages/AdminPage/components/ImageSortGrid/reorderItems.test.ts b/frontend/src/pages/AdminPage/components/ImageSortGrid/reorderItems.test.ts index b7983cb17..a86222963 100644 --- a/frontend/src/pages/AdminPage/components/ImageSortGrid/reorderItems.test.ts +++ b/frontend/src/pages/AdminPage/components/ImageSortGrid/reorderItems.test.ts @@ -1,5 +1,5 @@ -import { ImageItem } from './types'; import { reorderItems } from './reorderItems'; +import { ImageItem } from './types'; const makeUploaded = (url: string): ImageItem => ({ type: 'uploaded', url }); diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTabDesktop.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTabDesktop.tsx index 5b0eda1cd..67a2a53cb 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTabDesktop.tsx +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTabDesktop.tsx @@ -5,9 +5,9 @@ import { MAX_FILE_COUNT } from '@/constants/uploadLimit'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; import { ImageSortGrid } from '@/pages/AdminPage/components/ImageSortGrid/ImageSortGrid'; +import { ImageItem } from '@/pages/AdminPage/components/ImageSortGrid/types'; import { useDragSort } from '@/pages/AdminPage/components/ImageSortGrid/useDragSort'; import * as Styled from './PhotoEditTab.styles'; -import { ImageItem } from '@/pages/AdminPage/components/ImageSortGrid/types'; interface PhotoEditTabDesktopProps { feedItems: ImageItem[]; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTabMobile.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTabMobile.tsx index 50800b019..6e7cad684 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTabMobile.tsx +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTabMobile.tsx @@ -6,10 +6,10 @@ import { ADMIN_EVENT } from '@/constants/eventName'; import { MAX_FILE_COUNT } from '@/constants/uploadLimit'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { ImageSortGrid } from '@/pages/AdminPage/components/ImageSortGrid/ImageSortGrid'; -import PhotoUploadCard from './components/mobile/PhotoUploadCard/PhotoUploadCard'; +import { ImageItem } from '@/pages/AdminPage/components/ImageSortGrid/types'; import { useDragSort } from '@/pages/AdminPage/components/ImageSortGrid/useDragSort'; +import PhotoUploadCard from './components/mobile/PhotoUploadCard/PhotoUploadCard'; import * as Styled from './PhotoEditTabMobile.styles'; -import { ImageItem } from '@/pages/AdminPage/components/ImageSortGrid/types'; interface PhotoEditTabMobileProps { feedItems: ImageItem[]; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts index 76ad5c551..d3cb2343a 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/hooks/useFeedItems.ts @@ -1,17 +1,17 @@ import { useEffect, useRef, useState } from 'react'; import { useUpdateFeed, useUploadFeed } from '@/hooks/Queries/useClubImages'; -import { - extractLocalItems, - findOversizedFile, - hasPendingChanges, - sliceToLimit, -} from '../photoEditUtils'; import { buildFinalUrls } from '@/pages/AdminPage/components/ImageSortGrid/buildFinalUrls'; import { ImageItem, LocalItem, UploadedItem, } from '@/pages/AdminPage/components/ImageSortGrid/types'; +import { + extractLocalItems, + findOversizedFile, + hasPendingChanges, + sliceToLimit, +} from '../photoEditUtils'; export const useFeedItems = (clubId: string, originalFeeds: string[]) => { const { mutate: uploadFeed, isPending: isUploading } = useUploadFeed(); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.test.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.test.tsx index 2fdd760de..5d0dc6a88 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.test.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.test.tsx @@ -82,7 +82,8 @@ afterEach(() => { describe('PromotionEditTab 넘겨받은 토스트', () => { it('작성 중 이미지 업로드가 일부 실패해 넘어온 문구를 띄운다', () => { - const message = '글은 저장됐지만 이미지 2장 업로드에 실패했어요. 다시 올려주세요.'; + const message = + '글은 저장됐지만 이미지 2장 업로드에 실패했어요. 다시 올려주세요.'; renderEditTab({ toastMessage: message }); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/AdminPromotionCard/AdminPromotionCard.styles.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/AdminPromotionCard/AdminPromotionCard.styles.ts index b33da4ea9..feda93eef 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/AdminPromotionCard/AdminPromotionCard.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/AdminPromotionCard/AdminPromotionCard.styles.ts @@ -44,4 +44,3 @@ export const MoreButton = styled.button` background-color: ${colors.base.white}; } `; - diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts index 75c94d217..596db9fbd 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/utils/promotionForm.test.ts @@ -45,7 +45,9 @@ describe('BUILDING_OPTIONS', () => { expect(BUILDING_OPTIONS.length).toBe(uniqueCoordinates.size); expect( new Set( - BUILDING_OPTIONS.map((o) => `${o.coordinates.lat},${o.coordinates.lng}`), + BUILDING_OPTIONS.map( + (o) => `${o.coordinates.lat},${o.coordinates.lng}`, + ), ), ).toEqual(uniqueCoordinates); }); diff --git a/frontend/src/utils/formatKSTDateTime.test.ts b/frontend/src/utils/formatKSTDateTime.test.ts index 95860964d..e10c8d072 100644 --- a/frontend/src/utils/formatKSTDateTime.test.ts +++ b/frontend/src/utils/formatKSTDateTime.test.ts @@ -120,10 +120,19 @@ describe('formatKSTDateRange', () => { it('KST 기준으로 같은 날인지 판단한다 (UTC 기준이면 다른 날이 된다)', () => { expect( - formatKSTDateRange('2026-11-29T00:30:00+09:00', '2026-11-29T23:30:00+09:00'), + formatKSTDateRange( + '2026-11-29T00:30:00+09:00', + '2026-11-29T23:30:00+09:00', + ), ).toBe('11월 29일 일요일'); }); + it('종료일이 잘못된 값이면 시작만 보여준다', () => { + expect(formatKSTDateRange('2026-11-29T04:00:00+09:00', 'not-a-date')).toBe( + '11월 29일 일요일', + ); + }); + it('종료가 비면 시작만 보여준다', () => { expect(formatKSTDateRange('2026-11-29T04:00:00+09:00', '')).toBe( '11월 29일 일요일', diff --git a/frontend/src/utils/formatKSTDateTime.ts b/frontend/src/utils/formatKSTDateTime.ts index 3257b210b..c6795f8b0 100644 --- a/frontend/src/utils/formatKSTDateTime.ts +++ b/frontend/src/utils/formatKSTDateTime.ts @@ -42,8 +42,9 @@ const kstDayKey = (dateStr: string) => */ export const formatKSTDateRange = (startStr: string, endStr: string) => { if (!startStr) return ''; - if (!endStr || kstDayKey(startStr) === kstDayKey(endStr)) - return formatKSTDate(startStr); + // 종료일이 비었거나 파싱되지 않으면 하루짜리로 본다. 안 그러면 "11월 29일 ~ "로 끝난다 + const endKey = kstDayKey(endStr); + if (!endKey || endKey === kstDayKey(startStr)) return formatKSTDate(startStr); const short = (dateStr: string) => formatKSTDateTime(dateStr, { month: 'long', day: 'numeric' }); From 210a83b20d2dcc2ed792d74020ffa2e0cc2fd7cc Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Thu, 10 Sep 2026 15:26:22 +0900 Subject: [PATCH 25/28] =?UTF-8?q?feat(promotion):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=ED=99=8D=EB=B3=B4=20=ED=99=94=EB=A9=B4=EC=9D=98=20?= =?UTF-8?q?=EB=B9=A0=EC=A7=84=20=EC=9D=B8=ED=84=B0=EB=9E=99=EC=85=98=20?= =?UTF-8?q?=EB=91=90=20=EA=B3=B3=EC=97=90=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B6=99=EC=9D=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지도 카드 클릭과 이미지 더보기·접기가 추적되지 않아, 상세에서 위치를 확인했는지와 이미지를 끝까지 봤는지를 알 수 없었다. - Promotion Map Clicked: promotion_id, club_name, location - Promotion Image More Clicked: promotion_id, expanded, image_count 펼침과 접힘을 두 이벤트로 나누지 않고 expanded로 구분한다 갤러리는 images만 받고 있어 promotionId를 prop으로 하나 더 내린다. 관련 이벤트 추천(RelatedPromotionSection)은 showRelatedPromotion=false로 꺼져 있어 제외했다. --- frontend/src/constants/eventName.ts | 2 ++ .../PromotionPage/PromotionDetailPage.tsx | 5 ++++- .../PromotionImageGallery.tsx | 21 +++++++++++++++++-- .../PromotionMapSection.tsx | 14 ++++++++++++- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts index 339c50a9a..d4a28a40a 100644 --- a/frontend/src/constants/eventName.ts +++ b/frontend/src/constants/eventName.ts @@ -92,6 +92,8 @@ export const USER_EVENT = { PROMOTION_BUTTON_CLICKED: 'Promotion Button Clicked', PROMOTION_CARD_CLICKED: 'Promotion Card Clicked', PROMOTION_CLUB_CTA_CLICKED: 'Promotion Club CTA Clicked', + PROMOTION_MAP_CLICKED: 'Promotion Map Clicked', + PROMOTION_IMAGE_MORE_CLICKED: 'Promotion Image More Clicked', WEBVIEW_SUBSCRIBE_TOGGLED: 'Webview Subscribe Toggled', } as const; diff --git a/frontend/src/pages/PromotionPage/PromotionDetailPage.tsx b/frontend/src/pages/PromotionPage/PromotionDetailPage.tsx index eb1773aa2..23567e578 100644 --- a/frontend/src/pages/PromotionPage/PromotionDetailPage.tsx +++ b/frontend/src/pages/PromotionPage/PromotionDetailPage.tsx @@ -85,7 +85,10 @@ const PromotionDetail = () => { - + diff --git a/frontend/src/pages/PromotionPage/components/detail/PromotionImageGallery/PromotionImageGallery.tsx b/frontend/src/pages/PromotionPage/components/detail/PromotionImageGallery/PromotionImageGallery.tsx index 863a7af25..ca3cdcb4c 100644 --- a/frontend/src/pages/PromotionPage/components/detail/PromotionImageGallery/PromotionImageGallery.tsx +++ b/frontend/src/pages/PromotionPage/components/detail/PromotionImageGallery/PromotionImageGallery.tsx @@ -1,15 +1,22 @@ import { useEffect, useRef, useState } from 'react'; +import { USER_EVENT } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import ArrowButton from '../PromotionArrowButton/PromotionArrowButton'; import * as Styled from './PromotionImageGallery.styles'; interface PromotionImageGalleryProps { images: string[]; + promotionId: string; } const MAX_HEIGHT = 700; -const PromotionImageGallery = ({ images }: PromotionImageGalleryProps) => { +const PromotionImageGallery = ({ + images, + promotionId, +}: PromotionImageGalleryProps) => { const containerRef = useRef(null); + const trackEvent = useMixpanelTrack(); const [expanded, setExpanded] = useState(false); const [showButton, setShowButton] = useState(false); @@ -28,6 +35,16 @@ const PromotionImageGallery = ({ images }: PromotionImageGalleryProps) => { return () => observer.disconnect(); }, [images]); + const handleToggleExpanded = () => { + const nextExpanded = !expanded; + trackEvent(USER_EVENT.PROMOTION_IMAGE_MORE_CLICKED, { + promotion_id: promotionId, + expanded: nextExpanded, + image_count: images.length, + }); + setExpanded(nextExpanded); + }; + return ( @@ -43,7 +60,7 @@ const PromotionImageGallery = ({ images }: PromotionImageGalleryProps) => { setExpanded((prev) => !prev)} + onClick={handleToggleExpanded} /> )} diff --git a/frontend/src/pages/PromotionPage/components/detail/PromotionMapSection/PromotionMapSection.tsx b/frontend/src/pages/PromotionPage/components/detail/PromotionMapSection/PromotionMapSection.tsx index 9a3224ec9..7d086c8ba 100644 --- a/frontend/src/pages/PromotionPage/components/detail/PromotionMapSection/PromotionMapSection.tsx +++ b/frontend/src/pages/PromotionPage/components/detail/PromotionMapSection/PromotionMapSection.tsx @@ -3,6 +3,8 @@ import LocationIcon from '@/assets/images/icons/location_icon.svg?react'; import MapModal from '@/components/map/MapModal/MapModal'; import NaverMap from '@/components/map/NaverMap/NaverMap'; import { ClubLocation } from '@/constants/clubLocation'; +import { USER_EVENT } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useGetClubDetail } from '@/hooks/Queries/useClub'; import { PromotionArticle } from '@/types/promotion'; import * as Styled from './PromotionMapSection.styles'; @@ -13,6 +15,7 @@ interface Props { const PromotionMapSection = ({ article }: Props) => { const [isMapModalOpen, setIsMapModalOpen] = useState(false); + const trackEvent = useMixpanelTrack(); const { data: clubDetail } = useGetClubDetail(`@${article.clubName}`, { enabled: isMapModalOpen, staleTime: 60 * 60 * 1000, @@ -31,10 +34,19 @@ const PromotionMapSection = ({ article }: Props) => { detailLocation: '', }; + const handleMapClick = () => { + trackEvent(USER_EVENT.PROMOTION_MAP_CLICKED, { + promotion_id: article.id, + club_name: article.clubName, + location: article.location, + }); + setIsMapModalOpen(true); + }; + return ( <> - setIsMapModalOpen(true)}> + From 03d8ad9366cc0854ec7dde66ce3c2d72262ea581 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Thu, 10 Sep 2026 15:38:30 +0900 Subject: [PATCH 26/28] =?UTF-8?q?fix(admin):=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=8B=A4=ED=8C=A8=ED=95=9C=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=A5=BC=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B5=AC=EB=B6=84=ED=95=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 부분 실패 뒤 실패한 이미지가 성공 대기 중인 것과 똑같은 '업로드 예정' 배지를 달고 있어, 토스트 문구 말고는 어느 장이 안 올라갔는지 알 방법이 없었다. - uploadFiles가 URL을 못 받은 로컬 항목을 status: 'failed'로 표시한다. ImageSortGrid가 빨간 오버레이에 '실패'를 띄운다 - 실패 항목의 previewUrl은 revoke하지 않는다. 계속 보여줘야 한다 - 개별 재전송(onRetry)은 넘기지 않는다. 홍보는 저장 시 일괄 업로드라 다시 저장이 곧 재시도다. 개별 재전송을 붙이면 '이미지는 올라갔는데 글엔 반영 안 됨' 상태가 생긴다. 토스트 문구도 그에 맞게 고친다 --- .../tabs/PromotionTab/PromotionEditTab.tsx | 2 +- .../PromotionImageField.test.tsx | 37 ++++++++++ .../hooks/usePromotionForm.test.ts | 74 +++++++++++++++++++ .../PromotionTab/hooks/usePromotionForm.ts | 4 +- 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx index 33907cd7f..085597886 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx @@ -133,7 +133,7 @@ const PromotionEditTab = () => { return; } if (result.status === 'partial') { - const message = `글은 저장됐지만 이미지 ${result.failedCount}장 업로드에 실패했어요. 다시 올려주세요.`; + const message = `글은 저장됐지만 이미지 ${result.failedCount}장 업로드에 실패했어요. 실패한 이미지는 그대로 있으니 다시 저장해주세요.`; if (isEdit) { setToastMessage(message); } else { diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.test.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.test.tsx index 6b85568ce..be6f46b24 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.test.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/components/PromotionImageField/PromotionImageField.test.tsx @@ -1,7 +1,20 @@ import '@testing-library/jest-dom'; import { fireEvent, render, screen } from '@testing-library/react'; +import { ImageItem } from '@/pages/AdminPage/components/ImageSortGrid/types'; import PromotionImageField from './PromotionImageField'; +const renderWithImages = (images: ImageItem[]) => + render( + , + ); + const renderField = () => { const onAddFiles = jest.fn(); const onReject = jest.fn(); @@ -64,3 +77,27 @@ it('현재 장수와 상한을 보여준다', () => { renderField(); expect(screen.getByText('0/15')).toBeInTheDocument(); }); + +describe('업로드 실패 표시', () => { + const failedItem: ImageItem = { + type: 'local', + file: new File(['x'], 'bad.png', { type: 'image/png' }), + previewUrl: 'blob:bad.png', + status: 'failed', + }; + + it('실패한 이미지는 실패로 표시하고 업로드 예정으로 보여주지 않는다', () => { + renderWithImages([failedItem]); + + expect(screen.getByText('실패')).toBeInTheDocument(); + expect(screen.queryByText('업로드 예정')).not.toBeInTheDocument(); + }); + + it('재전송 버튼은 두지 않는다 - 홍보는 다시 저장이 곧 재시도다', () => { + renderWithImages([failedItem]); + + expect( + screen.queryByRole('button', { name: '재전송' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.test.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.test.ts index 8d61993e5..c957fc7e6 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.test.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.test.ts @@ -103,6 +103,80 @@ describe('usePromotionForm 이미지 순서', () => { }); }); + it('업로드에 실패한 이미지는 failed로 표시되고 미리보기는 살려 둔다', async () => { + const okFile = makeFile('ok.png'); + const badFile = makeFile('bad.png'); + uploadImages.mockResolvedValue({ + uploaded: [{ file: okFile, url: 'https://cdn/ok.png' }], + failedFiles: [badFile], + }); + updateArticle.mockResolvedValue({}); + + const { result } = renderHook(() => + usePromotionForm({ clubId: 'club-1', article }), + ); + + act(() => result.current.addFiles([okFile, badFile])); + await act(async () => { + await result.current.save(); + }); + + // File을 통째로 비교하면 실패 시 jest가 diff를 뜨다 힙을 터뜨린다. 스칼라만 본다 + const images = result.current.values.images; + expect( + images.map((item) => (item.type === 'uploaded' ? item.url : item.status)), + ).toEqual([ + 'https://cdn/old1.png', + 'https://cdn/old2.png', + 'https://cdn/ok.png', + 'failed', + ]); + + const failed = images[3]; + expect(failed.type).toBe('local'); + if (failed.type === 'local') { + expect(failed.file).toBe(badFile); + expect(failed.previewUrl).toBe('blob:bad.png'); + } + // 실패 항목은 계속 보여줘야 하므로 revoke하면 안 된다 + expect(global.URL.revokeObjectURL).not.toHaveBeenCalledWith('blob:bad.png'); + expect(global.URL.revokeObjectURL).toHaveBeenCalledWith('blob:ok.png'); + }); + + it('실패했던 이미지가 다시 저장에서 성공하면 uploaded로 바뀐다', async () => { + const badFile = makeFile('bad.png'); + uploadImages.mockResolvedValueOnce({ + uploaded: [], + failedFiles: [badFile], + }); + updateArticle.mockResolvedValue({}); + + const { result } = renderHook(() => + usePromotionForm({ clubId: 'club-1', article }), + ); + + act(() => result.current.addFiles([badFile])); + await act(async () => { + await result.current.save(); + }); + const firstTry = result.current.values.images[2]; + expect(firstTry.type).toBe('local'); + if (firstTry.type === 'local') expect(firstTry.status).toBe('failed'); + + uploadImages.mockResolvedValueOnce({ + uploaded: [{ file: badFile, url: 'https://cdn/late.png' }], + failedFiles: [], + }); + await act(async () => { + await result.current.save(); + }); + + const secondTry = result.current.values.images[2]; + expect(secondTry.type).toBe('uploaded'); + if (secondTry.type === 'uploaded') + expect(secondTry.url).toBe('https://cdn/late.png'); + }); + it('삭제한 로컬 이미지의 previewUrl은 revoke한다', () => { const { result } = renderHook(() => usePromotionForm({ clubId: 'club-1', article }), diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts index 62b647bb4..fabe2099e 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/hooks/usePromotionForm.ts @@ -116,12 +116,14 @@ export const usePromotionForm = ({ const urlByFile = new Map(uploaded.map(({ file, url }) => [file, url])); // 올라간 파일만 제자리에서 uploaded로 바꾼다. 일부 실패로 화면에 남았을 때 다시 저장해도 중복 업로드되지 않는다. + // 실패한 파일은 failed로 표시해 어느 장이 안 올라갔는지 화면에서 알 수 있게 한다. + // previewUrl은 계속 보여줘야 하므로 revoke하지 않는다. setValues((prev) => ({ ...prev, images: prev.images.map((item) => { if (item.type !== 'local') return item; const url = urlByFile.get(item.file); - if (!url) return item; + if (!url) return { ...item, status: 'failed' }; URL.revokeObjectURL(item.previewUrl); return { type: 'uploaded', url }; }), From f111db375ad41336c10118b94c52d5d29e75cfbd Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Mon, 14 Sep 2026 11:09:53 +0900 Subject: [PATCH 27/28] =?UTF-8?q?refactor(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=9E=85=EB=A0=A5=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=8B=A4=EB=A5=B8=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=83=AD=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EB=AA=A8=EC=96=91?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한 화면 안에 반경이 6px(InputField) / 8px(CustomTextArea) / 14px(Select) / 12px(DateTimeInput)로 섞여 있었다. 지난번 드롭다운만 14px로 맞추면서 어긋남이 더 커졌다. - InfoSection을 ClubIntroEditTab 전용에서 AdminPage/components로 올려 공유한다 - 제목·행사 장소·행사 설명을 InfoSection(레이블+카운터) + ClearableTextArea로 바꾼다. 카드가 14px·gray[50]이라 다른 모바일 탭과 같아진다 - ClearableTextArea에 optional disabled를 더한다. 없으면 저장 중·심사 전에도 편집돼 '폼이 잠기면 전부 잠긴다'가 깨진다. 기본 false라 기존 3곳은 무영향 - 지도 위치·행사 기간 라벨도 InfoSection 헤더와 같은 토큰을 쓴다 --- .../ClearableTextArea/ClearableTextArea.tsx | 5 +- .../InfoSection/InfoSection.stories.tsx | 2 +- .../InfoSection/InfoSection.styles.ts | 0 .../InfoSection/InfoSection.tsx | 0 .../ClubIntroEditTabMobile.tsx | 2 +- .../PromotionTab/PromotionEditTab.styles.ts | 6 +- .../PromotionTab/PromotionEditTab.test.tsx | 28 ++++++++- .../tabs/PromotionTab/PromotionEditTab.tsx | 61 +++++++++++-------- 8 files changed, 73 insertions(+), 31 deletions(-) rename frontend/src/pages/AdminPage/{tabs/ClubIntroEditTab/components/mobile => components}/InfoSection/InfoSection.stories.tsx (97%) rename frontend/src/pages/AdminPage/{tabs/ClubIntroEditTab/components/mobile => components}/InfoSection/InfoSection.styles.ts (100%) rename frontend/src/pages/AdminPage/{tabs/ClubIntroEditTab/components/mobile => components}/InfoSection/InfoSection.tsx (100%) diff --git a/frontend/src/pages/AdminPage/components/ClearableTextArea/ClearableTextArea.tsx b/frontend/src/pages/AdminPage/components/ClearableTextArea/ClearableTextArea.tsx index abb7dbe52..507555d54 100644 --- a/frontend/src/pages/AdminPage/components/ClearableTextArea/ClearableTextArea.tsx +++ b/frontend/src/pages/AdminPage/components/ClearableTextArea/ClearableTextArea.tsx @@ -11,6 +11,7 @@ interface ClearableTextAreaProps { maxLength?: number; rows?: number; size?: 'default' | 'large'; + disabled?: boolean; } const ClearableTextArea = ({ @@ -21,6 +22,7 @@ const ClearableTextArea = ({ maxLength, rows = 1, size = 'default', + disabled = false, }: ClearableTextAreaProps) => { const [isFocused, setIsFocused] = useState(false); const textareaRef = useAutoGrow(value); @@ -42,10 +44,11 @@ const ClearableTextArea = ({ maxLength={maxLength} rows={rows} $size={size} + disabled={disabled} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} /> - {isFocused && value.length > 0 && ( + {!disabled && isFocused && value.length > 0 && ( +const renderEditTab = (state: unknown, clubState = 'AVAILABLE') => render( } + element={} > { expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); }); + +describe('폼 잠금', () => { + const PLACEHOLDERS = [ + '행사 제목을 입력해주세요', + '예) 한솔관(E16) A동 208호', + '행사 내용, 참여 방법, 준비물 등을 적어주세요', + ]; + + it('심사 전 동아리는 세 입력 필드가 모두 잠긴다', () => { + renderEditTab(null, 'UNAVAILABLE'); + + PLACEHOLDERS.forEach((placeholder) => { + expect(screen.getByPlaceholderText(placeholder)).toBeDisabled(); + }); + }); + + it('심사가 끝난 동아리는 입력할 수 있다', () => { + renderEditTab(null); + + PLACEHOLDERS.forEach((placeholder) => { + expect(screen.getByPlaceholderText(placeholder)).not.toBeDisabled(); + }); + }); +}); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx index 085597886..6325419c5 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx @@ -6,9 +6,7 @@ import { useParams, } from 'react-router-dom'; import Button from '@/components/common/Button/Button'; -import CustomTextArea from '@/components/common/CustomTextArea/CustomTextArea'; import FixedBottomButtonArea from '@/components/common/FixedBottomButtonArea/FixedBottomButtonArea'; -import InputField from '@/components/common/InputField/InputField'; import Spinner from '@/components/common/Spinner/Spinner'; import Toast from '@/components/common/Toast/Toast'; import WebviewTopBar from '@/components/common/WebviewTopBar/WebviewTopBar'; @@ -23,7 +21,9 @@ import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; import { useGetPromotionArticles } from '@/hooks/Queries/usePromotion'; import useDevice from '@/hooks/useDevice'; +import ClearableTextArea from '@/pages/AdminPage/components/ClearableTextArea/ClearableTextArea'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; +import InfoSection from '@/pages/AdminPage/components/InfoSection/InfoSection'; import DateTimeRangePicker from '@/pages/AdminPage/tabs/RecruitEditTab/components/DateTimeRangePicker/DateTimeRangePicker'; import { colors } from '@/styles/theme/colors'; import { ClubDetail } from '@/types/club'; @@ -194,15 +194,20 @@ const PromotionEditTab = () => { )} - setField('title', e.target.value)} - onClear={() => setField('title', '')} maxLength={PROMOTION_TITLE_MAX} - disabled={isFormDisabled} - /> + currentLength={values.title.length} + > + setField('title', value)} + placeholder='행사 제목을 입력해주세요' + maxLength={PROMOTION_TITLE_MAX} + disabled={isFormDisabled} + /> +
지도 위치 @@ -240,15 +245,20 @@ const PromotionEditTab = () => {
- setField('location', e.target.value)} - onClear={() => setField('location', '')} maxLength={PROMOTION_LOCATION_MAX} - disabled={isFormDisabled} - /> + currentLength={values.location.length} + > + setField('location', value)} + placeholder='예) 한솔관(E16) A동 208호' + maxLength={PROMOTION_LOCATION_MAX} + disabled={isFormDisabled} + /> +
행사 기간 @@ -284,16 +294,19 @@ const PromotionEditTab = () => { )}
- setField('description', e.target.value)} maxLength={PROMOTION_DESCRIPTION_MAX} - showMaxChar - disabled={isFormDisabled} - /> + currentLength={values.description.length} + > + setField('description', value)} + placeholder='행사 내용, 참여 방법, 준비물 등을 적어주세요' + maxLength={PROMOTION_DESCRIPTION_MAX} + disabled={isFormDisabled} + /> + Date: Tue, 15 Sep 2026 17:26:38 +0900 Subject: [PATCH 28/28] =?UTF-8?q?feat(admin):=20=ED=99=8D=EB=B3=B4=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=BB=B4=ED=8C=A9=ED=8A=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=20=EC=9D=B4=20=ED=99=94=EB=A9=B4=EC=9D=B4=20?= =?UTF-8?q?=EB=AC=B4=EC=97=87=EC=9D=B8=EC=A7=80=20=EC=95=8C=EB=A0=A4?= =?UTF-8?q?=EC=A3=BC=EB=8A=94=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20=EB=84=A3?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상단바 제목 바로 아래에서 입력 필드가 시작해, 다른 관리자 모바일 탭과 달리 이 화면이 무엇을 하는 곳인지 알려주는 부분이 없었다. ClubIntroEditTabMobile과 같은 형태로 제목 + 부제를 둔다. 데스크톱은 ContentSection 헤더가 제목을 이미 보여주므로 컴팩트에만 넣는다. 스타일은 사용처가 둘뿐이라 공통으로 올리지 않고 같은 토큰으로 복제했다. --- .../PromotionTab/PromotionEditTab.styles.ts | 13 ++++++++ .../PromotionTab/PromotionEditTab.test.tsx | 32 +++++++++++++++++-- .../tabs/PromotionTab/PromotionEditTab.tsx | 10 +++++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.styles.ts b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.styles.ts index 922dec4bc..d7767d94a 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.styles.ts @@ -28,6 +28,19 @@ export const Container = styled.div` } `; +/* ClubIntroEditTabMobile의 페이지 설명과 같은 토큰. 사용처가 둘뿐이라 아직 공통으로 올리지 않았다 */ +export const PageTitle = styled.h2` + ${setTypography(typography.title.title5)} + color: ${colors.base.black}; + margin: 0; +`; + +export const PageSubtitle = styled.p` + ${setTypography(typography.button.button1)} + color: ${colors.gray[700]}; + margin: 4px 0 0; +`; + export const CompactBody = styled.div` display: flex; flex-direction: column; diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.test.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.test.tsx index 128cf7f60..635aa0abb 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.test.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.test.tsx @@ -20,12 +20,13 @@ jest.mock('@/hooks/Queries/usePromotion', () => ({ })); jest.mock('@/hooks/Mixpanel/useMixpanelTrack', () => () => jest.fn()); jest.mock('@/hooks/Mixpanel/useTrackPageView', () => () => {}); -jest.mock('@/hooks/useDevice', () => () => ({ +const mockDevice = { isMobile: false, isTablet: false, isLaptop: false, isDesktop: true, -})); +}; +jest.mock('@/hooks/useDevice', () => () => mockDevice); jest.mock('@/components/map/NaverMap/NaverMap', () => () =>
); // react-datepicker의 css import를 jest가 파싱하지 못해 통째로 대체한다 jest.mock( @@ -69,6 +70,12 @@ const renderEditTab = (state: unknown, clubState = 'AVAILABLE') => ); beforeEach(() => { + Object.assign(mockDevice, { + isMobile: false, + isTablet: false, + isLaptop: false, + isDesktop: true, + }); mockArticles.length = 0; mockArticles.push(article); const root = document.createElement('div'); @@ -120,3 +127,24 @@ describe('폼 잠금', () => { }); }); }); + +describe('페이지 설명', () => { + it('컴팩트에서는 이 화면이 무엇인지 알려준다', () => { + mockDevice.isMobile = true; + mockDevice.isDesktop = false; + renderEditTab(null); + + expect(screen.getByText('행사 정보를 입력해주세요')).toBeInTheDocument(); + expect( + screen.getByText('동아리 행사를 홍보하는 곳이에요'), + ).toBeInTheDocument(); + }); + + it('데스크톱에는 두지 않는다 - ContentSection 헤더가 제목을 이미 보여준다', () => { + renderEditTab(null); + + expect( + screen.queryByText('동아리 행사를 홍보하는 곳이에요'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx index 6325419c5..522ac3498 100644 --- a/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/PromotionTab/PromotionEditTab.tsx @@ -327,7 +327,15 @@ const PromotionEditTab = () => { {isCompact ? ( <> goToList()} /> - {fields} + +
+ 행사 정보를 입력해주세요 + + 동아리 행사를 홍보하는 곳이에요 + +
+ {fields} +
{isApproved && (