From ab2f531fe3738739a0030352ea0e7d598b19bd68 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Thu, 11 Jun 2026 18:00:15 +0900 Subject: [PATCH 01/24] =?UTF-8?q?feat:=20=EC=97=85=EC=9E=A5=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=8B=A0=EC=B2=AD=20=EB=82=B4=EC=97=AD/=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EB=B0=8F=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=ED=82=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.tsx | 10 ++++++++++ src/shared/constants/routes.ts | 11 +++++++++++ src/shared/lib/queryKeys.ts | 14 ++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/src/app/App.tsx b/src/app/App.tsx index 956f4956..2799f6c5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -26,6 +26,8 @@ import { AppliedStoresPage } from '@/pages/user/applied-stores' import { SubstituteRequestPage } from '@/pages/user/substitute-request' import { ManagerSubstituteRequestPage } from '@/pages/manager/substitute-request' 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 { WorkerListPage } from '@/pages/manager/worker-list' import { WorkspaceJoinPage } from '@/pages/user/workspace-join' @@ -143,6 +145,14 @@ export function App() { path={ROUTES.MANAGER.STORE_REGISTER} element={} /> + } + /> + } + /> } diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index 235a735c..0421918e 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -46,10 +46,21 @@ export const ROUTES = { PROFILE_SOCIAL: '/my/profile/social', WITHDRAW: '/my/withdraw', }, + /** 업장 등록 신청(승급 신청) — USER·MANAGER 공용, MANAGER 전용 가드 없음 */ + STORE_REGISTER: { + /** 신청 내역 목록 */ + REQUESTS: '/store-register/requests', + /** 신청 상세 (파라미터) */ + REQUEST_DETAIL_PATTERN: '/store-register/requests/:requestId', + }, NOTIFICATIONS: '/notifications', NOTIFICATION_SETTINGS: '/notifications/settings', } as const +export function storeRegisterRequestDetailPath(requestId: number) { + return `/store-register/requests/${requestId}` +} + export function managerWorkerSchedulePath( workspaceId: number, workerId: number diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index 46936b0d..2b1b0840 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -90,6 +90,20 @@ export const queryKeys = { list: (workspaceId: number) => ['fixedWorkerSchedule', 'list', workspaceId] as const, }, + storeRegisterRequest: { + list: (scope: 'MANAGER' | 'USER' | null) => + ['storeRegisterRequest', 'list', scope] as const, + detail: (scope: 'MANAGER' | 'USER' | null, requestId: number) => + ['storeRegisterRequest', 'detail', scope, requestId] as const, + reasons: (scope: 'MANAGER' | 'USER' | null, requestId: number) => + ['storeRegisterRequest', 'reasons', scope, requestId] as const, + comments: ( + scope: 'MANAGER' | 'USER' | null, + requestId: number, + reasonId: number + ) => + ['storeRegisterRequest', 'comments', scope, requestId, reasonId] as const, + }, notification: { list: (scope: 'MANAGER' | 'USER' | null, type?: string) => ['notifications', scope, type] as const, From a9838ea43f8f5a183ad1840edf10009c6d2aca88 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Thu, 11 Jun 2026 18:00:16 +0900 Subject: [PATCH 02/24] =?UTF-8?q?feat:=20=EC=97=85=EC=9E=A5=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=9A=94=EC=B2=AD=20API=EB=A5=BC=20scope=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=B7=A8=EC=86=8C/=EB=B0=98=EB=A0=A4?= =?UTF-8?q?=EC=82=AC=EC=9C=A0/=EB=8C=93=EA=B8=80=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store-register/api/workspaceFileUpload.ts | 17 +++- .../store-register/api/workspaceRequests.ts | 79 ++++++++++++++++--- .../store-register/types/workspaceRequests.ts | 59 +++++++++++++- src/shared/api/appFileUpload.ts | 12 ++- 4 files changed, 151 insertions(+), 16 deletions(-) diff --git a/src/features/store-register/api/workspaceFileUpload.ts b/src/features/store-register/api/workspaceFileUpload.ts index b7c88be7..a2c28906 100644 --- a/src/features/store-register/api/workspaceFileUpload.ts +++ b/src/features/store-register/api/workspaceFileUpload.ts @@ -27,11 +27,26 @@ const KIND_TO_TARGET: Record< /** 업장 등록 신청용 파일 업로드 (targetType·bucket 고정 조합). */ export async function uploadWorkspaceRegistrationFile( file: File, - kind: WorkspaceRegistrationAttachmentKind + kind: WorkspaceRegistrationAttachmentKind, + scope?: 'MANAGER' | 'USER' | null ): Promise { return uploadAppFile({ file, targetType: KIND_TO_TARGET[kind], bucketType: WORKSPACE_REGISTRATION_BUCKET, + scope, + }) +} + +/** 반려 사유 댓글 첨부 파일 업로드 (targetType=WORKSPACE_REASON_COMMENT). */ +export async function uploadWorkspaceReasonCommentFile( + file: File, + scope?: 'MANAGER' | 'USER' | null +): Promise { + return uploadAppFile({ + file, + targetType: 'WORKSPACE_REASON_COMMENT', + bucketType: WORKSPACE_REGISTRATION_BUCKET, + scope, }) } diff --git a/src/features/store-register/api/workspaceRequests.ts b/src/features/store-register/api/workspaceRequests.ts index 85733f1b..cb03b4cb 100644 --- a/src/features/store-register/api/workspaceRequests.ts +++ b/src/features/store-register/api/workspaceRequests.ts @@ -1,39 +1,100 @@ import axiosInstance from '@/shared/lib/axiosInstance' +import { getAuthApiBasePath } from '@/shared/lib/authApiPath' import type { CommonApiResponse } from '@/shared/types/common' import type { + CreateWorkspaceReasonCommentBody, + WorkspaceReasonCommentDto, + WorkspaceReasonCommentsApiResponse, WorkspaceRequestDetailApiResponse, WorkspaceRequestDetailDto, WorkspaceRegistrationCreateBody, + WorkspaceRequestReasonDto, + WorkspaceRequestReasonsApiResponse, WorkspaceRequestsListApiResponse, WorkspaceRequestListItemDto, } from '@/features/store-register/types/workspaceRequests' -/** GET /app/workspace-requests */ -export async function fetchWorkspaceRequests(): Promise< - WorkspaceRequestListItemDto[] -> { +type AuthScope = 'MANAGER' | 'USER' | null | undefined + +/** `/app/workspace-requests` | `/manager/workspace-requests` */ +function requestsBasePath(scope: AuthScope): string { + return `/${getAuthApiBasePath(scope)}/workspace-requests` +} + +/** GET /{scope}/workspace-requests */ +export async function fetchWorkspaceRequests( + scope: AuthScope +): Promise { const response = await axiosInstance.get( - '/app/workspace-requests' + requestsBasePath(scope) ) return response.data.data } -/** GET /app/workspace-requests/{workspaceRequestId} */ +/** GET /{scope}/workspace-requests/{workspaceRequestId} */ export async function fetchWorkspaceRequestDetail( + scope: AuthScope, workspaceRequestId: number ): Promise { const response = await axiosInstance.get( - `/app/workspace-requests/${workspaceRequestId}` + `${requestsBasePath(scope)}/${workspaceRequestId}` ) return response.data.data } -/** POST /app/workspace-requests */ +/** POST /{scope}/workspace-requests */ export async function createWorkspaceRegistrationRequest( + scope: AuthScope, body: WorkspaceRegistrationCreateBody ): Promise { await axiosInstance.post>>( - '/app/workspace-requests', + requestsBasePath(scope), + body + ) +} + +/** POST /{scope}/workspace-requests/{id}/cancel — PENDING/REVOKED 에서만 성공 */ +export async function cancelWorkspaceRequest( + scope: AuthScope, + workspaceRequestId: number +): Promise { + await axiosInstance.post>>( + `${requestsBasePath(scope)}/${workspaceRequestId}/cancel` + ) +} + +/** GET /{scope}/workspace-requests/{id}/reasons */ +export async function fetchWorkspaceRequestReasons( + scope: AuthScope, + workspaceRequestId: number +): Promise { + const response = await axiosInstance.get( + `${requestsBasePath(scope)}/${workspaceRequestId}/reasons` + ) + return response.data.data +} + +/** GET /{scope}/workspace-requests/{id}/reasons/{reasonId}/comments */ +export async function fetchWorkspaceReasonComments( + scope: AuthScope, + workspaceRequestId: number, + reasonId: number +): Promise { + const response = await axiosInstance.get( + `${requestsBasePath(scope)}/${workspaceRequestId}/reasons/${reasonId}/comments` + ) + return response.data.data +} + +/** POST /{scope}/workspace-requests/{id}/reasons/{reasonId}/comments */ +export async function createWorkspaceReasonComment( + scope: AuthScope, + workspaceRequestId: number, + reasonId: number, + body: CreateWorkspaceReasonCommentBody +): Promise { + await axiosInstance.post>>( + `${requestsBasePath(scope)}/${workspaceRequestId}/reasons/${reasonId}/comments`, body ) } diff --git a/src/features/store-register/types/workspaceRequests.ts b/src/features/store-register/types/workspaceRequests.ts index a5ebebac..5a69875f 100644 --- a/src/features/store-register/types/workspaceRequests.ts +++ b/src/features/store-register/types/workspaceRequests.ts @@ -5,6 +5,13 @@ export interface WorkspaceRequestStatusDto { description: string } +/** 신청 상태 — PENDING(검토 중) → ACTIVATED | REVOKED | CANCELED */ +export type WorkspaceRequestStatusValue = + | 'PENDING' + | 'ACTIVATED' + | 'REVOKED' + | 'CANCELED' + export interface WorkspaceRequestListItemDto { id: number businessName: string @@ -21,15 +28,19 @@ export interface WorkspaceRequestDetailDto { id: number businessRegistrationNo: string businessName: string + /** 대표자 성명 */ + ownerName: string businessType: string contact: string status: WorkspaceRequestStatusDto fullAddress: string latitude: number longitude: number + /** 서버가 presigned URL로 변환해 줌 → 그대로 열기만 함 */ workspaceCertFileUrl: string workspaceOwnIdentityFileUrl: string - workspaceWarrantFileUrl: string + /** 위임장은 선택 — 미첨부 시 null */ + workspaceWarrantFileUrl: string | null createdAt: string updatedAt: string } @@ -37,9 +48,11 @@ export interface WorkspaceRequestDetailDto { export type WorkspaceRequestDetailApiResponse = CommonApiResponse -/** POST `/app/workspace-requests` JSON 본문 (위·경도는 백엔드 요건에 따라 생략 가능) */ +/** POST `/{scope}/workspace-requests` JSON 본문 */ export type WorkspaceRegistrationCreateBody = { bizName: string + /** 대표자 성명 (신규·필수) */ + ownerName: string brn: string address: string province: string @@ -49,5 +62,45 @@ export type WorkspaceRegistrationCreateBody = { contact: string workspaceCertFileId: string workspaceOwnIdentityFileId: string - workspaceWarrantFileId: string + /** 위임장 — 선택/nullable */ + workspaceWarrantFileId: string | null } & Partial<{ latitude: number; longitude: number }> + +/** 반려 사유 */ +export interface WorkspaceRequestReasonDto { + id: number + reason: string + createdAt: string +} + +export type WorkspaceRequestReasonsApiResponse = CommonApiResponse< + WorkspaceRequestReasonDto[] +> + +export interface WorkspaceReasonCommentFileDto { + fileId: string + url: string +} + +export type WorkspaceReasonCommentOwner = 'USER' | 'ADMIN' + +/** 반려 사유에 달린 댓글 (게시판 글-댓글 스타일) */ +export interface WorkspaceReasonCommentDto { + id: number + workspaceReasonId: number + userId: number + commentOwner: WorkspaceReasonCommentOwner + comment: string + files: WorkspaceReasonCommentFileDto[] + createdAt: string +} + +export type WorkspaceReasonCommentsApiResponse = CommonApiResponse< + WorkspaceReasonCommentDto[] +> + +/** POST 댓글 본문 — comment ≤ 255자, fileIds 선택 */ +export interface CreateWorkspaceReasonCommentBody { + comment: string + fileIds?: string[] +} diff --git a/src/shared/api/appFileUpload.ts b/src/shared/api/appFileUpload.ts index be9701e7..09eb6806 100644 --- a/src/shared/api/appFileUpload.ts +++ b/src/shared/api/appFileUpload.ts @@ -1,4 +1,5 @@ import axiosInstance from '@/shared/lib/axiosInstance' +import { getAuthApiBasePath } from '@/shared/lib/authApiPath' import type { CommonApiResponse } from '@/shared/types/common' /** POST /app/files — query `targetType` (Swagger) */ @@ -15,7 +16,10 @@ export type AppFileUploadTargetType = export type AppFileBucketType = 'PUBLIC' | 'PRIVATE' -const APP_FILES_PATH = '/app/files' +/** scope 별 파일 업로드 경로 — MANAGER → /manager/files, 그 외 → /app/files */ +function getFilesPath(scope?: 'MANAGER' | 'USER' | null): string { + return `/${getAuthApiBasePath(scope)}/files` +} function extractUploadedFileId(data: unknown): string { if (typeof data !== 'object' || data === null) { @@ -32,21 +36,23 @@ function extractUploadedFileId(data: unknown): string { } /** - * POST /app/files + * POST /{app|manager}/files * multipart 파트 이름 `file` + query targetType, bucketType * (Swagger 의 application/json 예시는 실제 업로드와 다를 수 있음) + * scope 미지정 시 기존 동작(`/app/files`) 유지 */ export async function uploadAppFile(options: { file: File targetType: AppFileUploadTargetType bucketType: AppFileBucketType + scope?: 'MANAGER' | 'USER' | null }): Promise { const formData = new FormData() formData.append('file', options.file) const response = await axiosInstance.post< CommonApiResponse<{ fileId?: string; id?: string } | unknown> - >(APP_FILES_PATH, formData, { + >(getFilesPath(options.scope), formData, { params: { targetType: options.targetType, bucketType: options.bucketType, From 31d5dcecb17f40259027ca7ffffcb47569b8efcb Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Thu, 11 Jun 2026 18:00:51 +0900 Subject: [PATCH 03/24] =?UTF-8?q?feat:=20=EC=97=85=EC=9E=A5=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=8B=A0=EC=B2=AD=20=EB=82=B4=EC=97=AD/=EC=83=81?= =?UTF-8?q?=EC=84=B8/=EB=B0=98=EB=A0=A4=EC=82=AC=EC=9C=A0=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useReasonCommentThreadViewModel.ts | 100 ++++++++++++++ .../hooks/useStoreRegisterReasonsViewModel.ts | 29 ++++ .../useStoreRegisterRequestDetailViewModel.ts | 95 +++++++++++++ .../useStoreRegisterRequestsViewModel.ts | 24 ++++ src/features/store-register/lib/formatDate.ts | 21 +++ .../store-register/lib/requestStatus.ts | 46 +++++++ src/features/store-register/ui/ReasonCard.tsx | 62 +++++++++ .../ui/ReasonCommentComposer.tsx | 94 +++++++++++++ .../store-register/ui/ReasonCommentItem.tsx | 50 +++++++ .../store-register/ui/ReasonSection.tsx | 42 ++++++ .../ui/RequestDocumentsSection.tsx | 75 +++++++++++ .../store-register/ui/RequestInfoSection.tsx | 37 +++++ .../ui/StoreRequestListCard.tsx | 32 +++++ .../ui/StoreRequestStatusBadge.tsx | 17 +++ .../store-register/request-detail/index.tsx | 127 ++++++++++++++++++ src/pages/store-register/requests/index.tsx | 91 +++++++++++++ tailwind.config.js | 5 + 17 files changed, 947 insertions(+) create mode 100644 src/features/store-register/hooks/useReasonCommentThreadViewModel.ts create mode 100644 src/features/store-register/hooks/useStoreRegisterReasonsViewModel.ts create mode 100644 src/features/store-register/hooks/useStoreRegisterRequestDetailViewModel.ts create mode 100644 src/features/store-register/hooks/useStoreRegisterRequestsViewModel.ts create mode 100644 src/features/store-register/lib/formatDate.ts create mode 100644 src/features/store-register/lib/requestStatus.ts create mode 100644 src/features/store-register/ui/ReasonCard.tsx create mode 100644 src/features/store-register/ui/ReasonCommentComposer.tsx create mode 100644 src/features/store-register/ui/ReasonCommentItem.tsx create mode 100644 src/features/store-register/ui/ReasonSection.tsx create mode 100644 src/features/store-register/ui/RequestDocumentsSection.tsx create mode 100644 src/features/store-register/ui/RequestInfoSection.tsx create mode 100644 src/features/store-register/ui/StoreRequestListCard.tsx create mode 100644 src/features/store-register/ui/StoreRequestStatusBadge.tsx create mode 100644 src/pages/store-register/request-detail/index.tsx create mode 100644 src/pages/store-register/requests/index.tsx diff --git a/src/features/store-register/hooks/useReasonCommentThreadViewModel.ts b/src/features/store-register/hooks/useReasonCommentThreadViewModel.ts new file mode 100644 index 00000000..6f978f2b --- /dev/null +++ b/src/features/store-register/hooks/useReasonCommentThreadViewModel.ts @@ -0,0 +1,100 @@ +import { useCallback, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import useAuthStore from '@/shared/stores/useAuthStore' +import { useCertificateFilePick } from '@/shared/hooks/useCertificateFilePick' +import { queryKeys } from '@/shared/lib/queryKeys' +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import { + createWorkspaceReasonComment, + fetchWorkspaceReasonComments, +} from '@/features/store-register/api/workspaceRequests' +import { uploadWorkspaceReasonCommentFile } from '@/features/store-register/api/workspaceFileUpload' + +export const COMMENT_MAX_LENGTH = 255 + +/** 첨부 파일 ≤ 5MB */ +const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024 + +/** 반려 사유 한 건의 댓글 스레드 + 작성 ViewModel */ +export function useReasonCommentThreadViewModel( + requestId: number, + reasonId: number, + enabled: boolean +) { + const queryClient = useQueryClient() + const scope = useAuthStore(state => state.scope) + const attachment = useCertificateFilePick({ maxBytes: ATTACHMENT_MAX_BYTES }) + + const [comment, setComment] = useState('') + const [submitError, setSubmitError] = useState(null) + + const commentsKey = queryKeys.storeRegisterRequest.comments( + scope, + requestId, + reasonId + ) + + const query = useQuery({ + queryKey: commentsKey, + queryFn: () => fetchWorkspaceReasonComments(scope, requestId, reasonId), + enabled: enabled && Number.isFinite(requestId) && Number.isFinite(reasonId), + }) + + const mutation = useMutation({ + mutationFn: async () => { + const trimmed = comment.trim() + let fileIds: string[] | undefined + if (attachment.file) { + const fileId = await uploadWorkspaceReasonCommentFile( + attachment.file, + scope + ) + fileIds = [fileId] + } + await createWorkspaceReasonComment(scope, requestId, reasonId, { + comment: trimmed, + fileIds, + }) + }, + onSuccess: async () => { + setComment('') + attachment.clear() + await queryClient.invalidateQueries({ queryKey: commentsKey }) + }, + onError: (e: unknown) => { + setSubmitError( + getAxiosErrorMessage( + e, + '댓글을 등록하지 못했습니다. 잠시 후 다시 시도해 주세요.' + ) + ) + }, + }) + + const onCommentChange = useCallback((value: string) => { + setSubmitError(null) + setComment(value.slice(0, COMMENT_MAX_LENGTH)) + }, []) + + const canSubmit = + comment.trim().length > 0 && !mutation.isPending && !attachment.error + + const submit = useCallback(() => { + if (!canSubmit) return + setSubmitError(null) + mutation.mutate() + }, [canSubmit, mutation]) + + return { + comments: query.data ?? [], + isLoading: query.isPending && query.fetchStatus !== 'idle', + isError: query.isError, + comment, + onCommentChange, + attachment, + submitError, + isSubmitting: mutation.isPending, + canSubmit, + submit, + } +} diff --git a/src/features/store-register/hooks/useStoreRegisterReasonsViewModel.ts b/src/features/store-register/hooks/useStoreRegisterReasonsViewModel.ts new file mode 100644 index 00000000..9150dd36 --- /dev/null +++ b/src/features/store-register/hooks/useStoreRegisterReasonsViewModel.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query' +import useAuthStore from '@/shared/stores/useAuthStore' +import { queryKeys } from '@/shared/lib/queryKeys' +import { fetchWorkspaceRequestReasons } from '@/features/store-register/api/workspaceRequests' + +/** 신청 상세의 반려 사유 목록 (최신순) */ +export function useStoreRegisterReasonsViewModel( + requestId: number, + enabled: boolean +) { + const scope = useAuthStore(state => state.scope) + + const query = useQuery({ + queryKey: queryKeys.storeRegisterRequest.reasons(scope, requestId), + queryFn: () => fetchWorkspaceRequestReasons(scope, requestId), + enabled: enabled && Number.isFinite(requestId), + }) + + // 최신순 정렬 (createdAt 내림차순) + const reasons = [...(query.data ?? [])].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + + return { + reasons, + isLoading: query.isPending && query.fetchStatus !== 'idle', + isError: query.isError, + } +} diff --git a/src/features/store-register/hooks/useStoreRegisterRequestDetailViewModel.ts b/src/features/store-register/hooks/useStoreRegisterRequestDetailViewModel.ts new file mode 100644 index 00000000..159f0e92 --- /dev/null +++ b/src/features/store-register/hooks/useStoreRegisterRequestDetailViewModel.ts @@ -0,0 +1,95 @@ +import { useCallback, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import useAuthStore from '@/shared/stores/useAuthStore' +import { logoutSession } from '@/shared/api/auth' +import { queryKeys } from '@/shared/lib/queryKeys' +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import { ROUTES } from '@/shared/constants/routes' +import { + cancelWorkspaceRequest, + fetchWorkspaceRequestDetail, +} from '@/features/store-register/api/workspaceRequests' + +/** 업장 등록 신청 상세 ViewModel — 상세 조회 + 취소 + 재로그인 */ +export function useStoreRegisterRequestDetailViewModel(requestId: number) { + const navigate = useNavigate() + const queryClient = useQueryClient() + const isLoggedIn = useAuthStore(state => state.isLoggedIn) + const scope = useAuthStore(state => state.scope) + const logout = useAuthStore(state => state.logout) + + const [cancelError, setCancelError] = useState(null) + const [isConfirmOpen, setIsConfirmOpen] = useState(false) + + const query = useQuery({ + queryKey: queryKeys.storeRegisterRequest.detail(scope, requestId), + queryFn: () => fetchWorkspaceRequestDetail(scope, requestId), + enabled: isLoggedIn && Number.isFinite(requestId), + }) + + const cancelMutation = useMutation({ + mutationFn: () => cancelWorkspaceRequest(scope, requestId), + onSuccess: async () => { + setIsConfirmOpen(false) + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.storeRegisterRequest.list(scope), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.storeRegisterRequest.detail(scope, requestId), + }), + ]) + }, + onError: (e: unknown) => { + setCancelError( + getAxiosErrorMessage( + e, + '신청을 취소하지 못했습니다. 잠시 후 다시 시도해 주세요.' + ) + ) + }, + }) + + const openCancelConfirm = useCallback(() => { + setCancelError(null) + setIsConfirmOpen(true) + }, []) + + const closeCancelConfirm = useCallback(() => { + if (cancelMutation.isPending) return + setIsConfirmOpen(false) + }, [cancelMutation.isPending]) + + const confirmCancel = useCallback(() => { + setCancelError(null) + cancelMutation.mutate() + }, [cancelMutation]) + + /** 승인 완료 시 사장님 계정으로 다시 로그인 — 세션 정리 후 로그인 화면 */ + const reLogin = useCallback(async () => { + try { + await logoutSession(scope, isLoggedIn) + } catch { + // 서버 로그아웃 실패해도 로컬 세션은 정리 + } finally { + logout() + navigate(ROUTES.AUTH.LOGIN, { replace: true }) + } + }, [isLoggedIn, logout, navigate, scope]) + + return { + scope, + requestId, + detail: query.data ?? null, + isLoading: query.isPending && query.fetchStatus !== 'idle', + isError: query.isError, + isConfirmOpen, + isCanceling: cancelMutation.isPending, + cancelError, + openCancelConfirm, + closeCancelConfirm, + confirmCancel, + reLogin, + } +} diff --git a/src/features/store-register/hooks/useStoreRegisterRequestsViewModel.ts b/src/features/store-register/hooks/useStoreRegisterRequestsViewModel.ts new file mode 100644 index 00000000..68205a5c --- /dev/null +++ b/src/features/store-register/hooks/useStoreRegisterRequestsViewModel.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query' +import useAuthStore from '@/shared/stores/useAuthStore' +import { queryKeys } from '@/shared/lib/queryKeys' +import { fetchWorkspaceRequests } from '@/features/store-register/api/workspaceRequests' + +/** 업장 등록 신청 내역 목록 ViewModel */ +export function useStoreRegisterRequestsViewModel() { + const isLoggedIn = useAuthStore(state => state.isLoggedIn) + const scope = useAuthStore(state => state.scope) + + const query = useQuery({ + queryKey: queryKeys.storeRegisterRequest.list(scope), + queryFn: () => fetchWorkspaceRequests(scope), + enabled: isLoggedIn, + staleTime: 30_000, + }) + + return { + requests: query.data ?? [], + isLoading: query.isPending && query.fetchStatus !== 'idle', + isError: query.isError, + refetch: query.refetch, + } +} diff --git a/src/features/store-register/lib/formatDate.ts b/src/features/store-register/lib/formatDate.ts new file mode 100644 index 00000000..f5497922 --- /dev/null +++ b/src/features/store-register/lib/formatDate.ts @@ -0,0 +1,21 @@ +/** ISO 문자열 → YYYY.MM.DD (유효하지 않으면 '-') */ +export function formatRequestDate(iso?: string | null): string { + if (!iso) return '-' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '-' + const yyyy = date.getFullYear() + const mm = String(date.getMonth() + 1).padStart(2, '0') + const dd = String(date.getDate()).padStart(2, '0') + return `${yyyy}.${mm}.${dd}` +} + +/** ISO 문자열 → YYYY.MM.DD HH:mm (유효하지 않으면 '-') */ +export function formatRequestDateTime(iso?: string | null): string { + if (!iso) return '-' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '-' + const base = formatRequestDate(iso) + const hh = String(date.getHours()).padStart(2, '0') + const min = String(date.getMinutes()).padStart(2, '0') + return `${base} ${hh}:${min}` +} diff --git a/src/features/store-register/lib/requestStatus.ts b/src/features/store-register/lib/requestStatus.ts new file mode 100644 index 00000000..c9120be2 --- /dev/null +++ b/src/features/store-register/lib/requestStatus.ts @@ -0,0 +1,46 @@ +import type { WorkspaceRequestStatusValue } from '@/features/store-register/types/workspaceRequests' + +export interface StatusBadgeStyle { + label: string + /** 배지 className (테두리·배경·글자색) */ + badgeClass: string +} + +const FALLBACK: StatusBadgeStyle = { + label: '확인 중', + badgeClass: 'border-line-1 bg-bg-dark text-text-70', +} + +/** 4상태 배지 스타일 (SubstituteApprovalCard 패턴 참고) */ +const STATUS_STYLE: Record = { + PENDING: { + label: '검토 중', + badgeClass: 'border-warning bg-warning-100 text-warning', + }, + ACTIVATED: { + label: '승인 완료', + badgeClass: 'border-main bg-main-100 text-main', + }, + REVOKED: { + label: '반려', + badgeClass: 'border-error bg-error/10 text-error', + }, + CANCELED: { + label: '취소', + badgeClass: 'border-line-1 bg-bg-dark text-text-50', + }, +} + +/** status.value(서버) → 배지 스타일. description 이 있으면 라벨로 우선 사용 */ +export function resolveStatusBadge(status: { + value: string + description?: string +}): StatusBadgeStyle { + const style = STATUS_STYLE[status.value as WorkspaceRequestStatusValue] + if (!style) { + return status.description + ? { ...FALLBACK, label: status.description } + : FALLBACK + } + return style +} diff --git a/src/features/store-register/ui/ReasonCard.tsx b/src/features/store-register/ui/ReasonCard.tsx new file mode 100644 index 00000000..1173f747 --- /dev/null +++ b/src/features/store-register/ui/ReasonCard.tsx @@ -0,0 +1,62 @@ +import { formatRequestDateTime } from '@/features/store-register/lib/formatDate' +import { useReasonCommentThreadViewModel } from '@/features/store-register/hooks/useReasonCommentThreadViewModel' +import { ReasonCommentItem } from '@/features/store-register/ui/ReasonCommentItem' +import { ReasonCommentComposer } from '@/features/store-register/ui/ReasonCommentComposer' +import type { WorkspaceRequestReasonDto } from '@/features/store-register/types/workspaceRequests' + +type Props = { + requestId: number + reason: WorkspaceRequestReasonDto +} + +/** 반려 사유 카드 + 댓글 스레드 + 입력창 */ +export function ReasonCard({ requestId, reason }: Props) { + const thread = useReasonCommentThreadViewModel(requestId, reason.id, true) + + return ( +
+
+

반려 사유

+

+ {reason.reason} +

+

+ {formatRequestDateTime(reason.createdAt)} +

+
+ +
+ {thread.isLoading ? ( +

+ 댓글을 불러오는 중입니다. +

+ ) : null} + {thread.isError ? ( +

+ 댓글을 불러오지 못했습니다. +

+ ) : null} + {!thread.isLoading && + !thread.isError && + thread.comments.length === 0 ? ( +

+ 보강 자료를 댓글로 남기면 운영자가 재검토합니다. +

+ ) : null} + {thread.comments.map(comment => ( + + ))} +
+ + +
+ ) +} diff --git a/src/features/store-register/ui/ReasonCommentComposer.tsx b/src/features/store-register/ui/ReasonCommentComposer.tsx new file mode 100644 index 00000000..d2d217a7 --- /dev/null +++ b/src/features/store-register/ui/ReasonCommentComposer.tsx @@ -0,0 +1,94 @@ +import { CERTIFICATE_ACCEPT_ATTR } from '@/shared/lib/certificateFileValidation' +import type { CertificatePickState } from '@/features/store-register/ui/CertificateUploader' +import { COMMENT_MAX_LENGTH } from '@/features/store-register/hooks/useReasonCommentThreadViewModel' + +type Props = { + comment: string + onCommentChange: (v: string) => void + attachment: CertificatePickState + submitError: string | null + isSubmitting: boolean + canSubmit: boolean + onSubmit: () => void +} + +/** 댓글 입력창 — 255자 카운터 + 파일 첨부 + 전송 */ +export function ReasonCommentComposer({ + comment, + onCommentChange, + attachment, + submitError, + isSubmitting, + canSubmit, + onSubmit, +}: Props) { + const { file, error, inputRef, onInputChange, openPicker, clear } = attachment + + return ( +
+ + +