From bf50e52e77478bf71ac4e4574fdab6dab8140b62 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 24 Jun 2026 18:50:22 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=97=85=EC=9E=A5=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EA=B4=80=EB=A6=AC=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=8F=20=EC=BF=BC=EB=A6=AC=ED=82=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/api/appFileUpload.ts | 1 + src/shared/constants/routes.ts | 6 ++++++ src/shared/lib/queryKeys.ts | 2 ++ 3 files changed, 9 insertions(+) diff --git a/src/shared/api/appFileUpload.ts b/src/shared/api/appFileUpload.ts index d30b73e..4c3dfca 100644 --- a/src/shared/api/appFileUpload.ts +++ b/src/shared/api/appFileUpload.ts @@ -8,6 +8,7 @@ export type AppFileUploadTargetType = | 'USER_CERTIFICATE' | 'POSTING' | 'WORKSPACE' + | 'WORKSPACE_REPRESENTATIVE_IMAGE' | 'WORKSPACE_CERTIFICATE' | 'WORKSPACE_OWN_IDENTITY' | 'WORKSPACE_WARRANT' diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index fb55540..ee395b4 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -30,6 +30,8 @@ export const ROUTES = { WORKER_SCHEDULE: '/manager/worker-schedule', WORKER_SCHEDULE_PATTERN: '/manager/workspaces/:workspaceId/workers/:workerId/schedule', + WORKSPACE_IMAGES_EDIT_PATTERN: + '/manager/workspaces/:workspaceId/images/edit', STORE_REGISTER: '/manager/store-register', SUBSTITUTE_REQUEST: '/manager/substitute-request', WORKER_INVITE: '/manager/worker-invite', @@ -66,3 +68,7 @@ export function managerWorkerSchedulePath( ) { return `/manager/workspaces/${workspaceId}/workers/${workerId}/schedule` } + +export function managerWorkspaceImagesEditPath(workspaceId: number) { + return `/manager/workspaces/${workspaceId}/images/edit` +} diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index e4cadb3..4437c6a 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -44,6 +44,8 @@ export const queryKeys = { workspaceId, workerId, ] as const, + images: (workspaceId: number) => + ['managerWorkspace', 'images', workspaceId] as const, }, posting: { list: (params?: { From 2d0b55c461a8e1c2e06118cfd84701e144f0c570 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 24 Jun 2026 18:50:28 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=97=85=EC=9E=A5=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=ED=8E=B8=EC=A7=91=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/App.tsx b/src/app/App.tsx index f5d4476..20665f5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -29,6 +29,7 @@ import { StoreRegisterPage } from '@/pages/manager/store-register' import { StoreRegisterRequestsPage } from '@/pages/store-register/requests' import { StoreRegisterRequestDetailPage } from '@/pages/store-register/request-detail' import { ManagerWorkerInvitePage } from '@/pages/manager/worker-invite' +import { WorkspaceImageEditPage } from '@/pages/manager/workspace-image-edit' import { WorkspaceJoinPage } from '@/pages/user/workspace-join' import { NotificationPage } from '@/pages/notification' import { NotificationSettingsPage } from '@/pages/notification/settings' @@ -156,6 +157,10 @@ export function App() { path={ROUTES.MANAGER.WORKER_INVITE} element={} /> + } + /> }> From b0ee79d9b9906a3bc5b300206303dfc747406eac Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 24 Jun 2026 18:50:35 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=82=AC=EC=9E=A5=EB=8B=98=20?= =?UTF-8?q?=ED=99=88=20=EB=B0=B0=EB=84=88=20=EB=8F=99=EC=A0=81=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=B0=8F=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=BA=90=EB=9F=AC=EC=85=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/manager/home/index.tsx | 64 ++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index 0dddb14..77f87a3 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -15,10 +15,17 @@ import { OngoingPostingCard } from '@/shared/ui/manager/OngoingPostingCard' import { SubstituteApprovalCard } from '@/shared/ui/manager/SubstituteApprovalCard' import { MoreButton } from '@/shared/ui/common/MoreButton' import { WorkCategoryBadge } from '@/shared/ui/home/WorkCategoryBadge' -import managerHomeBannerImage from '@/assets/manager-home-banner.jpg' import managerHomeBannerListIcon from '@/assets/icons/home/manager-home-banner-list.svg' import managerWorkspaceModalPlusIcon from '@/assets/icons/home/manager-workspace-modal-plus.svg' -import { ROUTES, managerWorkerSchedulePath } from '@/shared/constants/routes' +import { + ROUTES, + managerWorkerSchedulePath, + managerWorkspaceImagesEditPath, +} from '@/shared/constants/routes' +import { + useWorkspaceImagesQuery, + WorkspaceImageCarousel, +} from '@/features/manager/workspace-image' import { useResignWorkerMutation } from '@/features/manager/worker-list/hooks/mutation/useResignWorkerMutation' import { ConfirmModal } from '@/shared/ui/common/ConfirmModal' import { Modal } from '@/shared/ui/common/Modal' @@ -35,6 +42,7 @@ export function ManagerHomePage() { const [isResignErrorOpen, setIsResignErrorOpen] = useState(false) const [deleteTargetWorker, setDeleteTargetWorker] = useState(null) + const [isImageCarouselOpen, setIsImageCarouselOpen] = useState(false) const { todayWorkers, storeWorkers, @@ -62,6 +70,18 @@ export function ManagerHomePage() { const { mutate: resignWorker, isPending: isResigning } = useResignWorkerMutation(activeWorkspaceId ?? 0) + const { images: workspaceImages } = useWorkspaceImagesQuery(activeWorkspaceId) + const mainImageUrl = workspaceImages[0]?.url + + const handleBannerClick = () => { + if (activeWorkspaceId === null) return + if (workspaceImages.length > 0) { + setIsImageCarouselOpen(true) + } else { + navigate(managerWorkspaceImagesEditPath(activeWorkspaceId)) + } + } + return ( <>
@@ -70,14 +90,25 @@ export function ManagerHomePage() {
- 가게 배너 이미지 -
+ -
+

{workspaceDetail?.businessName ?? ''} @@ -94,8 +125,8 @@ export function ManagerHomePage() {

+ setIsImageCarouselOpen(false)} + onEdit={() => { + if (activeWorkspaceId === null) return + setIsImageCarouselOpen(false) + navigate(managerWorkspaceImagesEditPath(activeWorkspaceId)) + }} + /> + Date: Wed, 24 Jun 2026 18:50:44 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EC=97=85=EC=9E=A5=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace-image/api/workspaceImage.ts | 23 +++ .../constants/workspaceImage.ts | 9 + .../hooks/useUpdateWorkspaceImagesMutation.ts | 18 ++ .../hooks/useWorkspaceImageEditViewModel.ts | 151 +++++++++++++++ .../hooks/useWorkspaceImagesQuery.ts | 16 ++ src/features/manager/workspace-image/index.ts | 10 + .../workspace-image/types/workspaceImage.ts | 20 ++ .../ui/WorkspaceImageCarousel.tsx | 169 +++++++++++++++++ .../manager/workspace-image-edit/index.tsx | 177 ++++++++++++++++++ 9 files changed, 593 insertions(+) create mode 100644 src/features/manager/workspace-image/api/workspaceImage.ts create mode 100644 src/features/manager/workspace-image/constants/workspaceImage.ts create mode 100644 src/features/manager/workspace-image/hooks/useUpdateWorkspaceImagesMutation.ts create mode 100644 src/features/manager/workspace-image/hooks/useWorkspaceImageEditViewModel.ts create mode 100644 src/features/manager/workspace-image/hooks/useWorkspaceImagesQuery.ts create mode 100644 src/features/manager/workspace-image/index.ts create mode 100644 src/features/manager/workspace-image/types/workspaceImage.ts create mode 100644 src/features/manager/workspace-image/ui/WorkspaceImageCarousel.tsx create mode 100644 src/pages/manager/workspace-image-edit/index.tsx diff --git a/src/features/manager/workspace-image/api/workspaceImage.ts b/src/features/manager/workspace-image/api/workspaceImage.ts new file mode 100644 index 0000000..9b4c64e --- /dev/null +++ b/src/features/manager/workspace-image/api/workspaceImage.ts @@ -0,0 +1,23 @@ +import axiosInstance from '@/shared/lib/axiosInstance' +import type { + UpdateWorkspaceImagesRequest, + WorkspaceImagesApiResponse, +} from '@/features/manager/workspace-image/types/workspaceImage' + +/** 매니저 - 업장 대표 이미지 목록 조회 */ +export async function fetchWorkspaceImages( + workspaceId: number +): Promise { + const response = await axiosInstance.get( + `/manager/workspaces/${workspaceId}/images` + ) + return response.data +} + +/** 매니저 - 업장 대표 이미지 수정 (전체 교체) */ +export async function updateWorkspaceImages( + workspaceId: number, + body: UpdateWorkspaceImagesRequest +): Promise { + await axiosInstance.put(`/manager/workspaces/${workspaceId}/images`, body) +} diff --git a/src/features/manager/workspace-image/constants/workspaceImage.ts b/src/features/manager/workspace-image/constants/workspaceImage.ts new file mode 100644 index 0000000..cdc30a2 --- /dev/null +++ b/src/features/manager/workspace-image/constants/workspaceImage.ts @@ -0,0 +1,9 @@ +/** 대표 이미지는 최대 5장까지 등록 가능 (백엔드 제약과 동일) */ +export const MAX_WORKSPACE_IMAGE_COUNT = 5 + +/** 업로드 허용 형식 — JPG, PNG */ +export const WORKSPACE_IMAGE_ACCEPT = 'image/jpeg,image/png' +export const WORKSPACE_IMAGE_ALLOWED_TYPES = ['image/jpeg', 'image/png'] + +/** 파일 최대 용량 — 20MB (백엔드 제약과 동일) */ +export const WORKSPACE_IMAGE_MAX_BYTES = 20 * 1024 * 1024 diff --git a/src/features/manager/workspace-image/hooks/useUpdateWorkspaceImagesMutation.ts b/src/features/manager/workspace-image/hooks/useUpdateWorkspaceImagesMutation.ts new file mode 100644 index 0000000..f89dfde --- /dev/null +++ b/src/features/manager/workspace-image/hooks/useUpdateWorkspaceImagesMutation.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateWorkspaceImages } from '@/features/manager/workspace-image/api/workspaceImage' +import type { UpdateWorkspaceImagesRequest } from '@/features/manager/workspace-image/types/workspaceImage' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useUpdateWorkspaceImagesMutation(workspaceId: number) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (body: UpdateWorkspaceImagesRequest) => + updateWorkspaceImages(workspaceId, body), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.managerWorkspace.images(workspaceId), + }) + }, + }) +} diff --git a/src/features/manager/workspace-image/hooks/useWorkspaceImageEditViewModel.ts b/src/features/manager/workspace-image/hooks/useWorkspaceImageEditViewModel.ts new file mode 100644 index 0000000..571e52c --- /dev/null +++ b/src/features/manager/workspace-image/hooks/useWorkspaceImageEditViewModel.ts @@ -0,0 +1,151 @@ +import { useEffect, useRef, useState } from 'react' +import { uploadAppFile } from '@/shared/api/appFileUpload' +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import { useWorkspaceImagesQuery } from '@/features/manager/workspace-image/hooks/useWorkspaceImagesQuery' +import { useUpdateWorkspaceImagesMutation } from '@/features/manager/workspace-image/hooks/useUpdateWorkspaceImagesMutation' +import { + MAX_WORKSPACE_IMAGE_COUNT, + WORKSPACE_IMAGE_ACCEPT, + WORKSPACE_IMAGE_ALLOWED_TYPES, + WORKSPACE_IMAGE_MAX_BYTES, +} from '@/features/manager/workspace-image/constants/workspaceImage' + +export interface EditableWorkspaceImage { + fileId: string + /** 서버 URL 또는 새로 추가한 파일의 미리보기 objectURL */ + url: string + isNew: boolean +} + +export function useWorkspaceImageEditViewModel(workspaceId: number) { + const { images, isLoading } = useWorkspaceImagesQuery(workspaceId) + const updateMutation = useUpdateWorkspaceImagesMutation(workspaceId) + + const fileInputRef = useRef(null) + const [items, setItems] = useState([]) + const [initialized, setInitialized] = useState(false) + const [error, setError] = useState('') + const [isUploading, setIsUploading] = useState(false) + + // 서버 데이터를 최초 1회 로컬 편집 상태로 복사 + useEffect(() => { + if (initialized || isLoading) return + setItems( + images.map(img => ({ fileId: img.fileId, url: img.url, isNew: false })) + ) + setInitialized(true) + }, [images, isLoading, initialized]) + + // 언마운트 시 새로 추가한 이미지의 objectURL 정리 + const itemsRef = useRef(items) + itemsRef.current = items + useEffect(() => { + return () => { + itemsRef.current.forEach(it => { + if (it.isNew) URL.revokeObjectURL(it.url) + }) + } + }, []) + + const canAddMore = items.length < MAX_WORKSPACE_IMAGE_COUNT + + const openPicker = () => { + setError('') + fileInputRef.current?.click() + } + + const onFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = '' + if (!file) return + + if (!canAddMore) { + setError( + `대표 이미지는 최대 ${MAX_WORKSPACE_IMAGE_COUNT}장까지 등록할 수 있어요.` + ) + return + } + if (!WORKSPACE_IMAGE_ALLOWED_TYPES.includes(file.type)) { + setError('JPG, PNG 형식의 이미지만 업로드할 수 있어요.') + return + } + if (file.size > WORKSPACE_IMAGE_MAX_BYTES) { + setError('이미지 용량은 20MB를 넘을 수 없어요.') + return + } + + try { + setError('') + setIsUploading(true) + const fileId = await uploadAppFile({ + file, + targetType: 'WORKSPACE_REPRESENTATIVE_IMAGE', + bucketType: 'PUBLIC', + scope: 'MANAGER', + }) + setItems(prev => [ + ...prev, + { fileId, url: URL.createObjectURL(file), isNew: true }, + ]) + } catch (err) { + setError(getAxiosErrorMessage(err, '이미지 업로드에 실패했어요.')) + } finally { + setIsUploading(false) + } + } + + const removeImage = (fileId: string) => { + setItems(prev => { + const target = prev.find(it => it.fileId === fileId) + if (target?.isNew) URL.revokeObjectURL(target.url) + return prev.filter(it => it.fileId !== fileId) + }) + } + + const setAsMain = (fileId: string) => { + setItems(prev => { + const index = prev.findIndex(it => it.fileId === fileId) + if (index <= 0) return prev + const next = [...prev] + const [picked] = next.splice(index, 1) + next.unshift(picked) + return next + }) + } + + const save = async () => { + setError('') + try { + await updateMutation.mutateAsync({ + images: items.map((it, index) => ({ + fileId: it.fileId, + sortOrder: index, + })), + }) + return true + } catch (err) { + setError( + getAxiosErrorMessage(err, '저장에 실패했어요. 다시 시도해 주세요.') + ) + return false + } + } + + return { + items, + totalCount: items.length, + maxCount: MAX_WORKSPACE_IMAGE_COUNT, + canAddMore, + fileInputRef, + accept: WORKSPACE_IMAGE_ACCEPT, + error, + isLoading, + isUploading, + isSaving: updateMutation.isPending, + openPicker, + onFileChange, + removeImage, + setAsMain, + save, + } +} diff --git a/src/features/manager/workspace-image/hooks/useWorkspaceImagesQuery.ts b/src/features/manager/workspace-image/hooks/useWorkspaceImagesQuery.ts new file mode 100644 index 0000000..d4e81c3 --- /dev/null +++ b/src/features/manager/workspace-image/hooks/useWorkspaceImagesQuery.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { fetchWorkspaceImages } from '@/features/manager/workspace-image/api/workspaceImage' +import { queryKeys } from '@/shared/lib/queryKeys' + +export function useWorkspaceImagesQuery(workspaceId: number | null) { + const { data, isPending, isError } = useQuery({ + queryKey: queryKeys.managerWorkspace.images(workspaceId!), + queryFn: () => fetchWorkspaceImages(workspaceId!), + enabled: workspaceId !== null, + }) + + const images = useMemo(() => data?.data ?? [], [data]) + + return { images, isLoading: isPending && workspaceId !== null, isError } +} diff --git a/src/features/manager/workspace-image/index.ts b/src/features/manager/workspace-image/index.ts new file mode 100644 index 0000000..cce145d --- /dev/null +++ b/src/features/manager/workspace-image/index.ts @@ -0,0 +1,10 @@ +export { useWorkspaceImagesQuery } from '@/features/manager/workspace-image/hooks/useWorkspaceImagesQuery' +export { useUpdateWorkspaceImagesMutation } from '@/features/manager/workspace-image/hooks/useUpdateWorkspaceImagesMutation' +export { useWorkspaceImageEditViewModel } from '@/features/manager/workspace-image/hooks/useWorkspaceImageEditViewModel' +export { WorkspaceImageCarousel } from '@/features/manager/workspace-image/ui/WorkspaceImageCarousel' +export type { + WorkspaceImageDto, + WorkspaceImagesApiResponse, + UpdateWorkspaceImageItem, + UpdateWorkspaceImagesRequest, +} from '@/features/manager/workspace-image/types/workspaceImage' diff --git a/src/features/manager/workspace-image/types/workspaceImage.ts b/src/features/manager/workspace-image/types/workspaceImage.ts new file mode 100644 index 0000000..5a062e5 --- /dev/null +++ b/src/features/manager/workspace-image/types/workspaceImage.ts @@ -0,0 +1,20 @@ +import type { CommonApiResponse } from '@/shared/types/common' + +/** GET /manager/workspaces/{workspaceId}/images 응답 아이템 */ +export interface WorkspaceImageDto { + fileId: string + url: string + sortOrder: number +} + +export type WorkspaceImagesApiResponse = CommonApiResponse + +/** PUT /manager/workspaces/{workspaceId}/images 요청 아이템 */ +export interface UpdateWorkspaceImageItem { + fileId: string + sortOrder: number +} + +export interface UpdateWorkspaceImagesRequest { + images: UpdateWorkspaceImageItem[] +} diff --git a/src/features/manager/workspace-image/ui/WorkspaceImageCarousel.tsx b/src/features/manager/workspace-image/ui/WorkspaceImageCarousel.tsx new file mode 100644 index 0000000..5b4cd8c --- /dev/null +++ b/src/features/manager/workspace-image/ui/WorkspaceImageCarousel.tsx @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useState } from 'react' +import { cn } from '@/shared/lib/utils' +import type { WorkspaceImageDto } from '@/features/manager/workspace-image/types/workspaceImage' + +interface WorkspaceImageCarouselProps { + isOpen: boolean + images: WorkspaceImageDto[] + onClose: () => void + onEdit: () => void +} + +/** + * 업장 대표 이미지 캐러셀 — 홈 카드 탭 시 열리는 전체화면 모달. + * 좌·우 화살표/썸네일로 모든 이미지를 넘겨 보고, 편집 버튼으로 수정 화면에 진입. + */ +export function WorkspaceImageCarousel({ + isOpen, + images, + onClose, + onEdit, +}: WorkspaceImageCarouselProps) { + const total = images.length + const [index, setIndex] = useState(0) + const [wasOpen, setWasOpen] = useState(isOpen) + + const goPrev = useCallback( + () => setIndex(i => (i - 1 + total) % total), + [total] + ) + const goNext = useCallback(() => setIndex(i => (i + 1) % total), [total]) + + // 열릴 때 첫 이미지로 초기화 (effect 대신 렌더 중 파생 상태 조정) + if (isOpen !== wasOpen) { + setWasOpen(isOpen) + if (isOpen) setIndex(0) + } + + // 스크롤 잠금 + 키보드 조작 + useEffect(() => { + if (!isOpen) return + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + if (event.key === 'ArrowLeft') goPrev() + if (event.key === 'ArrowRight') goNext() + } + window.addEventListener('keydown', handleKeyDown) + + return () => { + document.body.style.overflow = previousOverflow + window.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen, onClose, goPrev, goNext]) + + if (!isOpen || total === 0) return null + + const active = images[Math.min(index, total - 1)] + + return ( +
+ {/* 뒤 배경 — 메인 이미지 블러 */} + +
+ {/* 상단 바 */} +
+ + + {index + 1} / {total} + + +
+ + {/* 메인 이미지 */} +
+ 업장 이미지 + {total > 1 && ( + <> + + + + )} +
+ + {/* 썸네일 + 점 인디케이터 */} +
+
+ {images.map((image, k) => ( + + ))} +
+
+ {images.map((image, k) => ( + + ))} +
+
+
+
+ ) +} diff --git a/src/pages/manager/workspace-image-edit/index.tsx b/src/pages/manager/workspace-image-edit/index.tsx new file mode 100644 index 0000000..a903d1e --- /dev/null +++ b/src/pages/manager/workspace-image-edit/index.tsx @@ -0,0 +1,177 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { Navbar } from '@/shared/ui/common/Navbar' +import { useWorkspaceImageEditViewModel } from '@/features/manager/workspace-image' +import { ROUTES } from '@/shared/constants/routes' + +export function WorkspaceImageEditPage() { + const navigate = useNavigate() + const { workspaceId: workspaceIdParam } = useParams() + const workspaceId = Number(workspaceIdParam) + const isValidWorkspace = Number.isFinite(workspaceId) && workspaceId > 0 + + const goHome = () => navigate(ROUTES.MANAGER.HOME) + + if (!isValidWorkspace) { + return ( +
+ +
+

+ 업장 정보를 찾을 수 없습니다. +

+
+
+ ) + } + + return ( + + ) +} + +function WorkspaceImageEditContent({ + workspaceId, + onClose, +}: { + workspaceId: number + onClose: () => void +}) { + const { + items, + totalCount, + maxCount, + canAddMore, + fileInputRef, + accept, + error, + isLoading, + isUploading, + isSaving, + openPicker, + onFileChange, + removeImage, + setAsMain, + save, + } = useWorkspaceImageEditViewModel(workspaceId) + + const handleSave = async () => { + const ok = await save() + if (ok) onClose() + } + + return ( +
+ + + + +
+

+ 등록된 대표 이미지 {totalCount}장 +

+

+ 첫 번째 이미지가 홈 카드의 메인으로 노출됩니다. +

+ + {isLoading ? ( +

+ 불러오는 중… +

+ ) : ( +
+ {/* 추가 타일 */} + {canAddMore && ( + + )} + + {items.map((image, index) => ( +
+ + + {index === 0 ? ( + + 메인 + + ) : ( + + )} +
+ ))} +
+ )} + + {error && ( +

+ {error} +

+ )} + +

+ JPG, PNG 형식 · 최대 {maxCount}장까지 등록할 수 있어요. ‘메인으로 + 설정’을 누르면 홈 카드에 노출되는 메인 이미지를 변경할 수 있습니다. +

+
+ + {/* 푸터 */} +
+ + +
+
+ ) +} From f693f95d90a348e660b459e90162945d5aa12b00 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 24 Jun 2026 20:12:47 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=9C=A0=EC=8B=A4=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=90=EB=9F=AC=20UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useWorkspaceImagesQuery에서 refetch 노출 - useWorkspaceImageEditViewModel에서 조회 성공 시에만 초기화하도록 가드 강화 (실패 시 빈 목록으로 잠겨 전체 교체 저장으로 이미지 삭제되는 경로 제거) - 편집 페이지에 load-error 상태 추가: 에러 UI + 다시 시도 버튼 + 저장 비활성 - isLoadError 파생값으로 초기 로드 실패 상태 구분 - totalCount 중복 상태 제거, items.length 사용 - Spinner 추가, close/plus 아이콘 인라인 SVG로 교체 --- .../hooks/useWorkspaceImageEditViewModel.ts | 17 +- .../hooks/useWorkspaceImagesQuery.ts | 9 +- .../manager/workspace-image-edit/index.tsx | 199 ++++++++++++------ 3 files changed, 153 insertions(+), 72 deletions(-) diff --git a/src/features/manager/workspace-image/hooks/useWorkspaceImageEditViewModel.ts b/src/features/manager/workspace-image/hooks/useWorkspaceImageEditViewModel.ts index 571e52c..59c3f96 100644 --- a/src/features/manager/workspace-image/hooks/useWorkspaceImageEditViewModel.ts +++ b/src/features/manager/workspace-image/hooks/useWorkspaceImageEditViewModel.ts @@ -18,7 +18,8 @@ export interface EditableWorkspaceImage { } export function useWorkspaceImageEditViewModel(workspaceId: number) { - const { images, isLoading } = useWorkspaceImagesQuery(workspaceId) + const { images, isLoading, isError, refetch } = + useWorkspaceImagesQuery(workspaceId) const updateMutation = useUpdateWorkspaceImagesMutation(workspaceId) const fileInputRef = useRef(null) @@ -27,14 +28,19 @@ export function useWorkspaceImageEditViewModel(workspaceId: number) { const [error, setError] = useState('') const [isUploading, setIsUploading] = useState(false) - // 서버 데이터를 최초 1회 로컬 편집 상태로 복사 + // 서버 데이터를 최초 1회(조회 성공 시에만) 로컬 편집 상태로 복사. + // 조회 실패 시 빈 목록으로 초기화하면, 전체 교체 저장으로 기존 이미지가 + // 삭제될 수 있으므로 isError 동안에는 초기화하지 않는다. useEffect(() => { - if (initialized || isLoading) return + if (initialized || isLoading || isError) return setItems( images.map(img => ({ fileId: img.fileId, url: img.url, isNew: false })) ) setInitialized(true) - }, [images, isLoading, initialized]) + }, [images, isLoading, isError, initialized]) + + // 최초 조회가 실패해 편집할 데이터가 없는 상태(저장 차단 + 재시도 UI용) + const isLoadError = isError && !initialized // 언마운트 시 새로 추가한 이미지의 objectURL 정리 const itemsRef = useRef(items) @@ -133,13 +139,14 @@ export function useWorkspaceImageEditViewModel(workspaceId: number) { return { items, - totalCount: items.length, maxCount: MAX_WORKSPACE_IMAGE_COUNT, canAddMore, fileInputRef, accept: WORKSPACE_IMAGE_ACCEPT, error, isLoading, + isLoadError, + refetch, isUploading, isSaving: updateMutation.isPending, openPicker, diff --git a/src/features/manager/workspace-image/hooks/useWorkspaceImagesQuery.ts b/src/features/manager/workspace-image/hooks/useWorkspaceImagesQuery.ts index d4e81c3..e327457 100644 --- a/src/features/manager/workspace-image/hooks/useWorkspaceImagesQuery.ts +++ b/src/features/manager/workspace-image/hooks/useWorkspaceImagesQuery.ts @@ -4,7 +4,7 @@ import { fetchWorkspaceImages } from '@/features/manager/workspace-image/api/wor import { queryKeys } from '@/shared/lib/queryKeys' export function useWorkspaceImagesQuery(workspaceId: number | null) { - const { data, isPending, isError } = useQuery({ + const { data, isPending, isError, refetch } = useQuery({ queryKey: queryKeys.managerWorkspace.images(workspaceId!), queryFn: () => fetchWorkspaceImages(workspaceId!), enabled: workspaceId !== null, @@ -12,5 +12,10 @@ export function useWorkspaceImagesQuery(workspaceId: number | null) { const images = useMemo(() => data?.data ?? [], [data]) - return { images, isLoading: isPending && workspaceId !== null, isError } + return { + images, + isLoading: isPending && workspaceId !== null, + isError, + refetch, + } } diff --git a/src/pages/manager/workspace-image-edit/index.tsx b/src/pages/manager/workspace-image-edit/index.tsx index a903d1e..576dd61 100644 --- a/src/pages/manager/workspace-image-edit/index.tsx +++ b/src/pages/manager/workspace-image-edit/index.tsx @@ -1,5 +1,6 @@ import { useNavigate, useParams } from 'react-router-dom' import { Navbar } from '@/shared/ui/common/Navbar' +import { Spinner } from '@/shared/ui/Spinner' import { useWorkspaceImageEditViewModel } from '@/features/manager/workspace-image' import { ROUTES } from '@/shared/constants/routes' @@ -42,13 +43,14 @@ function WorkspaceImageEditContent({ }) { const { items, - totalCount, maxCount, canAddMore, fileInputRef, accept, error, isLoading, + isLoadError, + refetch, isUploading, isSaving, openPicker, @@ -76,82 +78,107 @@ function WorkspaceImageEditContent({ />
-

- 등록된 대표 이미지 {totalCount}장 -

-

- 첫 번째 이미지가 홈 카드의 메인으로 노출됩니다. -

- {isLoading ? ( -

- 불러오는 중… -

+
+ +
+ ) : isLoadError ? ( +
+

+ 대표 이미지를 불러오지 못했어요 +

+

+ 네트워크 연결을 확인한 뒤 다시 시도해 주세요. +

+ +
) : ( -
- {/* 추가 타일 */} - {canAddMore && ( - - )} + <> +

+ 등록된 대표 이미지 {items.length}장 +

+

+ 첫 번째 이미지가 홈 카드의 메인으로 노출됩니다. +

- {items.map((image, index) => ( -
- +
+ {/* 추가 타일 */} + {canAddMore && ( - {index === 0 ? ( - - 메인 + + {isUploading ? : } - ) : ( + + {isUploading ? '업로드 중' : '이미지 업로드'} + + + )} + + {items.map((image, index) => ( +
+ - )} -
- ))} -
- )} + {index === 0 ? ( + + 메인 + + ) : ( + + )} +
+ ))} +
- {error && ( -

- {error} -

- )} + {error && ( +

+ {error} +

+ )} -

- JPG, PNG 형식 · 최대 {maxCount}장까지 등록할 수 있어요. ‘메인으로 - 설정’을 누르면 홈 카드에 노출되는 메인 이미지를 변경할 수 있습니다. -

+

+ JPG, PNG 형식 · 최대 {maxCount}장까지 등록할 수 있어요. ‘메인으로 + 설정’을 누르면 홈 카드에 노출되는 메인 이미지를 변경할 수 + 있습니다. +

+ + )}
{/* 푸터 */} @@ -166,7 +193,7 @@ function WorkspaceImageEditContent({
) } + +/** 추가 타일의 + 아이콘 (currentColor) */ +function PlusGlyph() { + return ( + + ) +} + +/** 이미지 제거 버튼의 ✕ 아이콘 (currentColor) */ +function CloseGlyph() { + return ( + + ) +} From 153e0f7e0a164ce59a97eedbea04ca0c9a2f3304 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 24 Jun 2026 20:12:55 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=EC=BA=90=EB=9F=AC=EC=85=80=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=ED=81=B4=EB=9E=A8=ED=94=84=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=80=ED=99=94=20=EB=B0=8F=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=ED=86=A0=ED=81=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - safeIndex = Math.min(index, total-1)로 단일 클램프값 도입 (active 이미지, 카운터, 썸네일 하이라이트, 점 인디케이터 4곳 통일) - 이미지 길이 변경 시에도 UI 동기화 보장 - text-[18px] → text-5, text-[22px] → text-7 토큰 적용 - 유니코드 글리프(‹/›) → ChevronLeft/RightIcon SVG 컴포넌트 사용 - close(✕)/edit(✎) 글리프 → 인라인 currentColor SVG로 교체 --- .../ui/WorkspaceImageCarousel.tsx | 69 ++++++++++++++++--- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/src/features/manager/workspace-image/ui/WorkspaceImageCarousel.tsx b/src/features/manager/workspace-image/ui/WorkspaceImageCarousel.tsx index 5b4cd8c..8d7c0fb 100644 --- a/src/features/manager/workspace-image/ui/WorkspaceImageCarousel.tsx +++ b/src/features/manager/workspace-image/ui/WorkspaceImageCarousel.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { cn } from '@/shared/lib/utils' +import { ChevronLeftIcon } from '@/assets/icons/ChevronLeftIcon' +import { ChevronRightIcon } from '@/assets/icons/ChevronRightIcon' import type { WorkspaceImageDto } from '@/features/manager/workspace-image/types/workspaceImage' interface WorkspaceImageCarouselProps { @@ -56,7 +58,9 @@ export function WorkspaceImageCarousel({ if (!isOpen || total === 0) return null - const active = images[Math.min(index, total - 1)] + // images 길이가 줄어도 카운터·썸네일·점·표시 이미지가 어긋나지 않도록 클램프 + const safeIndex = Math.min(index, total - 1) + const active = images[safeIndex] return (
- ✕ + - {index + 1} / {total} + {safeIndex + 1} / {total}
@@ -108,17 +112,17 @@ export function WorkspaceImageCarousel({ type="button" aria-label="이전" onClick={goPrev} - className="absolute left-[26px] top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-[22px] text-white" + className="absolute left-[26px] top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white" > - ‹ + )} @@ -135,7 +139,7 @@ export function WorkspaceImageCarousel({ aria-label={`${k + 1}번째 이미지`} className={cn( 'relative h-16 w-16 flex-none overflow-hidden rounded-[10px] border-2', - k === index ? 'border-main' : 'border-transparent' + k === safeIndex ? 'border-main' : 'border-transparent' )} > ))} @@ -167,3 +171,46 @@ export function WorkspaceImageCarousel({
) } + +/** 닫기 ✕ 아이콘 (currentColor) */ +function CloseGlyph() { + return ( + + ) +} + +/** 편집 ✎ 아이콘 (currentColor) */ +function EditGlyph() { + return ( + + ) +} From 3ded19045a413d66b9e7fc2a1ce4b4115ab746b1 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Mon, 29 Jun 2026 10:56:01 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=BA=90=EB=9F=AC=EC=85=80=EC=9D=84=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EB=B6=80=20=EB=A0=8C=EB=8D=94=EB=A7=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/manager/home/index.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/pages/manager/home/index.tsx b/src/pages/manager/home/index.tsx index 77f87a3..aeae742 100644 --- a/src/pages/manager/home/index.tsx +++ b/src/pages/manager/home/index.tsx @@ -306,16 +306,17 @@ export function ManagerHomePage() {
- setIsImageCarouselOpen(false)} - onEdit={() => { - if (activeWorkspaceId === null) return - setIsImageCarouselOpen(false) - navigate(managerWorkspaceImagesEditPath(activeWorkspaceId)) - }} - /> + {isImageCarouselOpen && ( + setIsImageCarouselOpen(false)} + onEdit={() => { + if (activeWorkspaceId === null) return + setIsImageCarouselOpen(false) + navigate(managerWorkspaceImagesEditPath(activeWorkspaceId)) + }} + /> + )} Date: Mon, 29 Jun 2026 10:56:07 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20isOpen=20prop=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20wasOpen=20=ED=8C=8C=EC=83=9D=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=9C=EA=B1=B0=EB=A1=9C=20=EC=BA=90?= =?UTF-8?q?=EB=9F=AC=EC=85=80=20=EB=A1=9C=EC=A7=81=20=EA=B0=84=EC=86=8C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace-image/ui/WorkspaceImageCarousel.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/features/manager/workspace-image/ui/WorkspaceImageCarousel.tsx b/src/features/manager/workspace-image/ui/WorkspaceImageCarousel.tsx index 8d7c0fb..84d12c5 100644 --- a/src/features/manager/workspace-image/ui/WorkspaceImageCarousel.tsx +++ b/src/features/manager/workspace-image/ui/WorkspaceImageCarousel.tsx @@ -5,7 +5,6 @@ import { ChevronRightIcon } from '@/assets/icons/ChevronRightIcon' import type { WorkspaceImageDto } from '@/features/manager/workspace-image/types/workspaceImage' interface WorkspaceImageCarouselProps { - isOpen: boolean images: WorkspaceImageDto[] onClose: () => void onEdit: () => void @@ -14,16 +13,15 @@ interface WorkspaceImageCarouselProps { /** * 업장 대표 이미지 캐러셀 — 홈 카드 탭 시 열리는 전체화면 모달. * 좌·우 화살표/썸네일로 모든 이미지를 넘겨 보고, 편집 버튼으로 수정 화면에 진입. + * 열림 상태는 호출부에서 조건부 렌더링으로 제어하므로, 마운트 시 첫 이미지부터 시작한다. */ export function WorkspaceImageCarousel({ - isOpen, images, onClose, onEdit, }: WorkspaceImageCarouselProps) { const total = images.length const [index, setIndex] = useState(0) - const [wasOpen, setWasOpen] = useState(isOpen) const goPrev = useCallback( () => setIndex(i => (i - 1 + total) % total), @@ -31,15 +29,8 @@ export function WorkspaceImageCarousel({ ) const goNext = useCallback(() => setIndex(i => (i + 1) % total), [total]) - // 열릴 때 첫 이미지로 초기화 (effect 대신 렌더 중 파생 상태 조정) - if (isOpen !== wasOpen) { - setWasOpen(isOpen) - if (isOpen) setIndex(0) - } - // 스크롤 잠금 + 키보드 조작 useEffect(() => { - if (!isOpen) return const previousOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' @@ -54,9 +45,9 @@ export function WorkspaceImageCarousel({ document.body.style.overflow = previousOverflow window.removeEventListener('keydown', handleKeyDown) } - }, [isOpen, onClose, goPrev, goNext]) + }, [onClose, goPrev, goNext]) - if (!isOpen || total === 0) return null + if (total === 0) return null // images 길이가 줄어도 카운터·썸네일·점·표시 이미지가 어긋나지 않도록 클램프 const safeIndex = Math.min(index, total - 1)