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/features/store-register/api/workspaceFileUpload.ts b/src/features/store-register/api/workspaceFileUpload.ts index b7c88be7..b38028d3 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_REQUEST_COMMENT). */ +export async function uploadWorkspaceRequestCommentFile( + file: File, + scope?: 'MANAGER' | 'USER' | null +): Promise { + return uploadAppFile({ + file, + targetType: 'WORKSPACE_REQUEST_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..febe56f4 100644 --- a/src/features/store-register/api/workspaceRequests.ts +++ b/src/features/store-register/api/workspaceRequests.ts @@ -1,6 +1,10 @@ import axiosInstance from '@/shared/lib/axiosInstance' +import { getAuthApiBasePath } from '@/shared/lib/authApiPath' import type { CommonApiResponse } from '@/shared/types/common' import type { + CreateWorkspaceRequestCommentBody, + WorkspaceRequestCommentDto, + WorkspaceRequestCommentsApiResponse, WorkspaceRequestDetailApiResponse, WorkspaceRequestDetailDto, WorkspaceRegistrationCreateBody, @@ -8,32 +12,74 @@ import type { 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}/comments */ +export async function fetchWorkspaceRequestComments( + scope: AuthScope, + workspaceRequestId: number +): Promise { + const response = await axiosInstance.get( + `${requestsBasePath(scope)}/${workspaceRequestId}/comments` + ) + return response.data.data +} + +/** POST /{scope}/workspace-requests/{id}/comments */ +export async function createWorkspaceRequestComment( + scope: AuthScope, + workspaceRequestId: number, + body: CreateWorkspaceRequestCommentBody +): Promise { + await axiosInstance.post>>( + `${requestsBasePath(scope)}/${workspaceRequestId}/comments`, body ) } diff --git a/src/features/store-register/hooks/useRequestCommentThreadViewModel.ts b/src/features/store-register/hooks/useRequestCommentThreadViewModel.ts new file mode 100644 index 00000000..7888400f --- /dev/null +++ b/src/features/store-register/hooks/useRequestCommentThreadViewModel.ts @@ -0,0 +1,102 @@ +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 { + createWorkspaceRequestComment, + fetchWorkspaceRequestComments, +} from '@/features/store-register/api/workspaceRequests' +import { uploadWorkspaceRequestCommentFile } from '@/features/store-register/api/workspaceFileUpload' + +export const COMMENT_MAX_LENGTH = 255 + +/** 첨부 파일 ≤ 5MB */ +const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024 + +/** 신청 1건의 단일 댓글 스레드 + 작성 ViewModel */ +export function useRequestCommentThreadViewModel( + requestId: 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) + + const query = useQuery({ + queryKey: commentsKey, + queryFn: () => fetchWorkspaceRequestComments(scope, requestId), + enabled: enabled && Number.isFinite(requestId), + }) + + const mutation = useMutation({ + mutationFn: async () => { + const trimmed = comment.trim() + let fileIds: string[] | undefined + if (attachment.file) { + const fileId = await uploadWorkspaceRequestCommentFile( + attachment.file, + scope + ) + fileIds = [fileId] + } + await createWorkspaceRequestComment(scope, requestId, { + 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]) + + // 오래된 → 최신 정렬 (createdAt 오름차순, 대화 흐름) + const comments = [...(query.data ?? [])].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ) + + return { + comments, + messageCount: comments.length, + isLoading: query.isPending && query.fetchStatus !== 'idle', + isError: query.isError, + refetch: query.refetch, + comment, + onCommentChange, + attachment, + submitError, + isSubmitting: mutation.isPending, + canSubmit, + submit, + } +} diff --git a/src/features/store-register/hooks/useStoreRegisterRequestDetailViewModel.ts b/src/features/store-register/hooks/useStoreRegisterRequestDetailViewModel.ts new file mode 100644 index 00000000..c661b9d2 --- /dev/null +++ b/src/features/store-register/hooks/useStoreRegisterRequestDetailViewModel.ts @@ -0,0 +1,77 @@ +import { useCallback, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import useAuthStore from '@/shared/stores/useAuthStore' +import { queryKeys } from '@/shared/lib/queryKeys' +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import { + cancelWorkspaceRequest, + fetchWorkspaceRequestDetail, +} from '@/features/store-register/api/workspaceRequests' + +/** 업장 등록 신청 상세 ViewModel — 상세 조회 + 취소 */ +export function useStoreRegisterRequestDetailViewModel(requestId: number) { + const queryClient = useQueryClient() + const isLoggedIn = useAuthStore(state => state.isLoggedIn) + const scope = useAuthStore(state => state.scope) + + 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]) + + return { + scope, + requestId, + detail: query.data ?? null, + isLoading: query.isPending && query.fetchStatus !== 'idle', + isError: query.isError, + isConfirmOpen, + isCanceling: cancelMutation.isPending, + cancelError, + openCancelConfirm, + closeCancelConfirm, + confirmCancel, + } +} 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/hooks/useStoreRegisterWizard.ts b/src/features/store-register/hooks/useStoreRegisterWizard.ts index 2f4f1d0c..c4ca8293 100644 --- a/src/features/store-register/hooks/useStoreRegisterWizard.ts +++ b/src/features/store-register/hooks/useStoreRegisterWizard.ts @@ -2,22 +2,31 @@ import { useCallback, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useQueryClient } from '@tanstack/react-query' import { useCertificateFilePick } from '@/shared/hooks/useCertificateFilePick' +import useAuthStore from '@/shared/stores/useAuthStore' import { createWorkspaceRegistrationRequest } from '@/features/store-register/api/workspaceRequests' import { uploadWorkspaceRegistrationFile } from '@/features/store-register/api/workspaceFileUpload' +import { maskBrn, maskContact } from '@/features/store-register/lib/inputMask' import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import { queryKeys } from '@/shared/lib/queryKeys' import { ROUTES } from '@/shared/constants/routes' type Step = 'info' | 'certificate' | 'done' +/** 증명원: JPG/PNG/PDF ≤10MB, 신분증·위임장: ≤5MB */ +const CERTIFICATE_MAX_BYTES = 10 * 1024 * 1024 +const IDENTITY_MAX_BYTES = 5 * 1024 * 1024 + export function useStoreRegisterWizard() { const navigate = useNavigate() const queryClient = useQueryClient() - const certFile = useCertificateFilePick() - const identityFile = useCertificateFilePick() - const warrantFile = useCertificateFilePick() + const scope = useAuthStore(state => state.scope) + const certFile = useCertificateFilePick({ maxBytes: CERTIFICATE_MAX_BYTES }) + const identityFile = useCertificateFilePick({ maxBytes: IDENTITY_MAX_BYTES }) + const warrantFile = useCertificateFilePick({ maxBytes: IDENTITY_MAX_BYTES }) const [step, setStep] = useState('info') const [bizName, setBizName] = useState('') + const [ownerName, setOwnerName] = useState('') const [brn, setBrn] = useState('') const [province, setProvince] = useState('') const [district, setDistrict] = useState('') @@ -28,9 +37,16 @@ export function useStoreRegisterWizard() { const [submitError, setSubmitError] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) + const setBrnMasked = useCallback((v: string) => setBrn(maskBrn(v)), []) + const setContactMasked = useCallback( + (v: string) => setContact(maskContact(v)), + [] + ) + const infoValid = bizName.trim().length > 0 && - brn.trim().length > 0 && + ownerName.trim().length > 0 && + brn.replace(/\D/g, '').length === 10 && province.trim().length > 0 && district.trim().length > 0 && town.trim().length > 0 && @@ -38,19 +54,14 @@ export function useStoreRegisterWizard() { type.trim().length > 0 && contact.trim().length > 0 - const certificateValid = - !!certFile.file && !!identityFile.file && !!warrantFile.file + // 위임장은 선택 — 증명원·신분증만 필수 + const certificateValid = !!certFile.file && !!identityFile.file const goInfo = () => setStep('info') const goCertificate = () => setStep('certificate') const submit = useCallback(async () => { - if ( - !certFile.file || - !identityFile.file || - !warrantFile.file || - !infoValid - ) { + if (!certFile.file || !identityFile.file || !infoValid) { return } @@ -59,17 +70,23 @@ export function useStoreRegisterWizard() { try { let workspaceCertFileId: string let workspaceOwnIdentityFileId: string - let workspaceWarrantFileId: string + let workspaceWarrantFileId: string | null = null try { - ;[ - workspaceCertFileId, - workspaceOwnIdentityFileId, - workspaceWarrantFileId, - ] = await Promise.all([ - uploadWorkspaceRegistrationFile(certFile.file, 'CERTIFICATE'), - uploadWorkspaceRegistrationFile(identityFile.file, 'OWN_IDENTITY'), - uploadWorkspaceRegistrationFile(warrantFile.file, 'WARRANT'), + ;[workspaceCertFileId, workspaceOwnIdentityFileId] = await Promise.all([ + uploadWorkspaceRegistrationFile(certFile.file, 'CERTIFICATE', scope), + uploadWorkspaceRegistrationFile( + identityFile.file, + 'OWN_IDENTITY', + scope + ), ]) + if (warrantFile.file) { + workspaceWarrantFileId = await uploadWorkspaceRegistrationFile( + warrantFile.file, + 'WARRANT', + scope + ) + } } catch (e) { setSubmitError( getAxiosErrorMessage( @@ -81,8 +98,9 @@ export function useStoreRegisterWizard() { } try { - await createWorkspaceRegistrationRequest({ + await createWorkspaceRegistrationRequest(scope, { bizName, + ownerName, brn, province, district, @@ -108,6 +126,9 @@ export function useStoreRegisterWizard() { await queryClient.invalidateQueries({ queryKey: ['managerWorkspace'] }) await queryClient.invalidateQueries({ queryKey: ['workspace'] }) + await queryClient.invalidateQueries({ + queryKey: queryKeys.storeRegisterRequest.list(scope), + }) setStep('done') } finally { @@ -121,7 +142,9 @@ export function useStoreRegisterWizard() { district, identityFile.file, infoValid, + ownerName, province, + scope, town, type, warrantFile.file, @@ -130,10 +153,12 @@ export function useStoreRegisterWizard() { ]) const exitToHome = () => navigate(ROUTES.MANAGER.HOME) + const goRequests = () => navigate(ROUTES.STORE_REGISTER.REQUESTS) return { step, bizName, + ownerName, brn, province, district, @@ -142,13 +167,14 @@ export function useStoreRegisterWizard() { type, contact, setBizName, - setBrn, + setOwnerName, + setBrn: setBrnMasked, setProvince, setDistrict, setTown, setAddress, setType, - setContact, + setContact: setContactMasked, certFile, identityFile, warrantFile, @@ -160,5 +186,6 @@ export function useStoreRegisterWizard() { goCertificate, submit, exitToHome, + goRequests, } } 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/formatFileSize.ts b/src/features/store-register/lib/formatFileSize.ts new file mode 100644 index 00000000..29b6ad44 --- /dev/null +++ b/src/features/store-register/lib/formatFileSize.ts @@ -0,0 +1,10 @@ +/** 바이트 → 사람이 읽는 크기 (예: 1.2MB / 820KB) */ +export function formatFileSize(bytes: number): string { + if (bytes >= 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` + } + if (bytes >= 1024) { + return `${Math.round(bytes / 1024)}KB` + } + return `${bytes}B` +} diff --git a/src/features/store-register/lib/inputMask.ts b/src/features/store-register/lib/inputMask.ts new file mode 100644 index 00000000..d2b7f2b0 --- /dev/null +++ b/src/features/store-register/lib/inputMask.ts @@ -0,0 +1,29 @@ +/** 사업자등록번호 마스킹 — 000-00-00000 (숫자 10자리) */ +export function maskBrn(value: string): string { + const digits = value.replace(/\D/g, '').slice(0, 10) + const parts: string[] = [] + parts.push(digits.slice(0, 3)) + if (digits.length > 3) parts.push(digits.slice(3, 5)) + if (digits.length > 5) parts.push(digits.slice(5, 10)) + return parts.filter(Boolean).join('-') +} + +/** 연락처 마스킹 — 숫자만, 하이픈 포함 최대 13자 (휴대폰·유선 공용) */ +export function maskContact(value: string): string { + const digits = value.replace(/\D/g, '').slice(0, 11) + if (digits.length < 4) return digits + + // 02 지역번호(서울) 처리 + if (digits.startsWith('02')) { + if (digits.length <= 5) return `${digits.slice(0, 2)}-${digits.slice(2)}` + if (digits.length <= 9) + return `${digits.slice(0, 2)}-${digits.slice(2, 5)}-${digits.slice(5)}` + return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6, 10)}` + } + + // 휴대폰·일반 지역번호(3자리) + if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}` + if (digits.length <= 10) + return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}` + return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}` +} diff --git a/src/features/store-register/lib/requestStatus.ts b/src/features/store-register/lib/requestStatus.ts new file mode 100644 index 00000000..7d46cc3e --- /dev/null +++ b/src/features/store-register/lib/requestStatus.ts @@ -0,0 +1,49 @@ +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', + }, + CANCELLED: { + label: '취소', + badgeClass: 'border-line-1 bg-bg-dark text-text-50', + }, +} + +/** status.value(서버) → 배지 스타일. description 이 있으면 라벨로 우선 사용 */ +export function resolveStatusBadge(status: { + value: string + description?: string +}): StatusBadgeStyle { + const hasStyle = Object.prototype.hasOwnProperty.call( + STATUS_STYLE, + status.value + ) + if (!hasStyle) { + return status.description + ? { ...FALLBACK, label: status.description } + : FALLBACK + } + return STATUS_STYLE[status.value as WorkspaceRequestStatusValue] +} diff --git a/src/features/store-register/types/workspaceRequests.ts b/src/features/store-register/types/workspaceRequests.ts index a5ebebac..9abaeba4 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 | CANCELLED */ +export type WorkspaceRequestStatusValue = + | 'PENDING' + | 'ACTIVATED' + | 'REVOKED' + | 'CANCELLED' + 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,33 @@ export type WorkspaceRegistrationCreateBody = { contact: string workspaceCertFileId: string workspaceOwnIdentityFileId: string - workspaceWarrantFileId: string + /** 위임장 — 선택/nullable */ + workspaceWarrantFileId: string | null } & Partial<{ latitude: number; longitude: number }> + +export interface WorkspaceRequestCommentFileDto { + fileId: string + url: string +} + +export type WorkspaceRequestCommentOwner = 'USER' | 'ADMIN' + +/** 신청 1건에 매달리는 단일 스레드 댓글 (관리자 첫 댓글 = 반려 사유) */ +export interface WorkspaceRequestCommentDto { + id: number + userId: number + commentOwner: WorkspaceRequestCommentOwner + comment: string + files: WorkspaceRequestCommentFileDto[] + createdAt: string +} + +export type WorkspaceRequestCommentsApiResponse = CommonApiResponse< + WorkspaceRequestCommentDto[] +> + +/** POST 댓글 본문 — comment ≤ 255자, fileIds 선택 */ +export interface CreateWorkspaceRequestCommentBody { + comment: string + fileIds?: string[] +} diff --git a/src/features/store-register/ui/CertificateUploader.tsx b/src/features/store-register/ui/CertificateUploader.tsx index ea0d47f9..538801b2 100644 --- a/src/features/store-register/ui/CertificateUploader.tsx +++ b/src/features/store-register/ui/CertificateUploader.tsx @@ -1,42 +1,66 @@ +import type { ReactNode } from 'react' import { CERTIFICATE_ACCEPT_ATTR } from '@/shared/lib/certificateFileValidation' import { useCertificateFilePick } from '@/shared/hooks/useCertificateFilePick' +import { formatFileSize } from '@/features/store-register/lib/formatFileSize' +import { + CloseIcon, + FileIcon, + UploadIcon, + WarningTriangleIcon, +} from '@/features/store-register/ui/icons' export type CertificatePickState = Pick< ReturnType, - | 'file' - | 'previewUrl' - | 'error' - | 'isPdf' - | 'inputRef' - | 'onInputChange' - | 'openPicker' - | 'clear' + 'file' | 'error' | 'inputRef' | 'onInputChange' | 'openPicker' | 'clear' > type Props = { certificate: CertificatePickState headline: string hint?: string + /** 선택 항목이면 "선택" 배지, 아니면 "필수" 배지 */ + optional?: boolean + /** 선택된 파일 칩의 썸네일 아이콘 — 기본 문서 아이콘 */ + icon?: ReactNode +} + +function FieldBadge({ optional }: { optional: boolean }) { + if (optional) { + return ( + + 선택 + + ) + } + return ( + + 필수 + + ) } export function CertificateUploader({ certificate, headline, - hint = '촬영·스캔 이미지(JPG·PNG 등) 또는 PDF · 최대 15MB', + hint, + optional = false, + icon, }: Props) { - const { - file, - previewUrl, - error, - isPdf, - inputRef, - onInputChange, - openPicker, - clear, - } = certificate + const { file, error, inputRef, onInputChange, openPicker, clear } = + certificate return ( -
+
+
+ + {headline} + + +
+ {hint ? ( + {hint} + ) : null} + - - {headline} - - - {hint} - - - 파일 선택 + + + 파일을 선택해 주세요 ) : ( -
-
- {previewUrl ? ( - {`${file.name} - ) : ( -
- - PDF - -
- )} -
-

- {headline} -

-

- {file.name} -

-

- {isPdf - ? 'PDF 파일입니다. 운영자 검토까지 그대로 제출됩니다.' - : '이미지 파일입니다.'} -

-
- - -
-
+
+ + {icon ?? } + +
+

+ {file.name} +

+

+ {formatFileSize(file.size)} +

+
)} {error ? ( -

{error}

+
+ + {error} +
) : null}
) diff --git a/src/features/store-register/ui/RequestCommentComposer.tsx b/src/features/store-register/ui/RequestCommentComposer.tsx new file mode 100644 index 00000000..1cbacb38 --- /dev/null +++ b/src/features/store-register/ui/RequestCommentComposer.tsx @@ -0,0 +1,108 @@ +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/useRequestCommentThreadViewModel' +import { + CloseIcon, + PaperclipIcon, + SendIcon, +} from '@/features/store-register/ui/icons' + +type Props = { + comment: string + onCommentChange: (v: string) => void + attachment: CertificatePickState + submitError: string | null + isSubmitting: boolean + canSubmit: boolean + onSubmit: () => void + placeholder?: string + /** 스레드 로딩·실패 시 입력 비활성화 */ + disabled?: boolean +} + +/** 댓글 입력바 — 파일 첨부 + 단일 입력 + 전송, 255자 카운터 */ +export function RequestCommentComposer({ + comment, + onCommentChange, + attachment, + submitError, + isSubmitting, + canSubmit, + onSubmit, + placeholder = '추가 자료나 설명을 남겨 재심사를 요청하세요', + disabled = false, +}: Props) { + const { file, error, inputRef, onInputChange, openPicker, clear } = attachment + + return ( +
+ + + {file ? ( +
+ + {file.name} + + +
+ ) : null} + +
+ + onCommentChange(e.target.value)} + placeholder={placeholder} + maxLength={COMMENT_MAX_LENGTH} + disabled={disabled} + className="h-11 min-w-0 flex-1 rounded-xl border border-line-2 px-3.5 outline-none typography-body02-regular text-text-100 placeholder:text-text-50 focus:border-main disabled:cursor-not-allowed disabled:bg-bg-light disabled:text-text-50" + /> + +
+ + {!disabled ? ( + + {comment.length}/{COMMENT_MAX_LENGTH} + + ) : null} + + {error ? ( +

{error}

+ ) : null} + + {submitError ? ( +

{submitError}

+ ) : null} +
+ ) +} diff --git a/src/features/store-register/ui/RequestCommentItem.tsx b/src/features/store-register/ui/RequestCommentItem.tsx new file mode 100644 index 00000000..31444766 --- /dev/null +++ b/src/features/store-register/ui/RequestCommentItem.tsx @@ -0,0 +1,75 @@ +import { formatRequestDateTime } from '@/features/store-register/lib/formatDate' +import { FileIcon } from '@/features/store-register/ui/icons' +import type { + WorkspaceRequestCommentDto, + WorkspaceRequestCommentFileDto, +} from '@/features/store-register/types/workspaceRequests' + +type Props = { + comment: WorkspaceRequestCommentDto +} + +function FileChip({ + file, + isAdmin, +}: { + file: WorkspaceRequestCommentFileDto + isAdmin: boolean +}) { + return ( + + + 첨부파일 + + ) +} + +/** 댓글 한 건 — 관리자(ADMIN) 좌측 회색 / 본인(USER) 우측 main 톤 */ +export function RequestCommentItem({ comment }: Props) { + const isAdmin = comment.commentOwner === 'ADMIN' + const time = formatRequestDateTime(comment.createdAt) + + if (isAdmin) { + return ( +
+ + 관리자 + + {comment.comment ? ( +

+ {comment.comment} +

+ ) : null} + {comment.files.map(file => ( + + ))} + + {time} + +
+ ) + } + + return ( +
+ {comment.files.map(file => ( + + ))} + {comment.comment ? ( +

+ {comment.comment} +

+ ) : null} + + {time} + +
+ ) +} diff --git a/src/features/store-register/ui/RequestDocumentsSection.tsx b/src/features/store-register/ui/RequestDocumentsSection.tsx new file mode 100644 index 00000000..e258fde2 --- /dev/null +++ b/src/features/store-register/ui/RequestDocumentsSection.tsx @@ -0,0 +1,99 @@ +import type { ReactNode } from 'react' +import { FileIcon, IdCardIcon } from '@/features/store-register/ui/icons' +import type { WorkspaceRequestDetailDto } from '@/features/store-register/types/workspaceRequests' + +type DocItem = { + label: string + url: string | null + icon: ReactNode +} + +/** 첨부된 서류 — 탭하면 presigned URL 을 새 탭에서 연다 */ +function AttachedDoc({ + label, + url, + icon, +}: { + label: string + url: string + icon: ReactNode +}) { + return ( + + + {icon} + +
+

{label}

+

+ 탭하여 새 탭에서 열기 +

+
+
+ ) +} + +/** 미첨부 서류 — 점선 보더 + 회색 아이콘 */ +function MissingDoc({ label, icon }: { label: string; icon: ReactNode }) { + return ( +
+ + {icon} + +
+

{label}

+

미첨부

+
+
+ ) +} + +type Props = { + detail: WorkspaceRequestDetailDto +} + +/** 증빙 서류 — 사업자등록증명원 / 대표자 신분증 / 위임장 */ +export function RequestDocumentsSection({ detail }: Props) { + const docs: DocItem[] = [ + { + label: '사업자등록증명원', + url: detail.workspaceCertFileUrl, + icon: , + }, + { + label: '대표자 신분증', + url: detail.workspaceOwnIdentityFileUrl, + icon: , + }, + { + label: '위임장', + url: detail.workspaceWarrantFileUrl, + icon: , + }, + ] + + return ( +
+

증빙 서류

+
+ {docs.map(doc => + doc.url ? ( + + ) : ( + + ) + )} +
+
+ ) +} diff --git a/src/features/store-register/ui/RequestInfoSection.tsx b/src/features/store-register/ui/RequestInfoSection.tsx new file mode 100644 index 00000000..8d18a666 --- /dev/null +++ b/src/features/store-register/ui/RequestInfoSection.tsx @@ -0,0 +1,40 @@ +import type { WorkspaceRequestDetailDto } from '@/features/store-register/types/workspaceRequests' + +type Props = { + detail: WorkspaceRequestDetailDto +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + + {value || '-'} + +
+ ) +} + +/** 업장 정보 — 업장명·대표자성명·사업자번호·형태·연락처·주소 */ +export function RequestInfoSection({ detail }: Props) { + return ( +
+

업장 정보

+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ ) +} diff --git a/src/features/store-register/ui/RequestListSkeleton.tsx b/src/features/store-register/ui/RequestListSkeleton.tsx new file mode 100644 index 00000000..32c92dd6 --- /dev/null +++ b/src/features/store-register/ui/RequestListSkeleton.tsx @@ -0,0 +1,25 @@ +/** 신청 내역 목록 로딩 스켈레톤 — 카드 3개 */ +function SkeletonCard() { + return ( +
+
+ + +
+ + +
+ ) +} + +export function RequestListSkeleton() { + return ( +
    + {[0, 1, 2].map(i => ( +
  • + +
  • + ))} +
+ ) +} diff --git a/src/features/store-register/ui/RequestThreadSection.tsx b/src/features/store-register/ui/RequestThreadSection.tsx new file mode 100644 index 00000000..bef0611d --- /dev/null +++ b/src/features/store-register/ui/RequestThreadSection.tsx @@ -0,0 +1,132 @@ +import { useRequestCommentThreadViewModel } from '@/features/store-register/hooks/useRequestCommentThreadViewModel' +import { RequestCommentItem } from '@/features/store-register/ui/RequestCommentItem' +import { RequestCommentComposer } from '@/features/store-register/ui/RequestCommentComposer' + +type ThreadVariant = 'PENDING' | 'REVOKED' | 'ACTIVATED' + +type Props = { + requestId: number + variant: ThreadVariant +} + +type VariantCopy = { + emptyTitle: string + emptyDescription: string + placeholder: string +} + +/** 입력바/빈 상태 안내는 상호작용 variant(PENDING·REVOKED)에서만 사용 */ +const VARIANT_COPY: Record<'PENDING' | 'REVOKED', VariantCopy> = { + PENDING: { + emptyTitle: '아직 주고받은 메시지가 없어요', + emptyDescription: + '검토 관련 문의나 보완 자료를 미리 남기면 관리자가 답변해 드려요.', + placeholder: '관리자에게 메시지를 남겨 보세요', + }, + REVOKED: { + emptyTitle: '아직 관리자가 남긴 코멘트가 없어요', + emptyDescription: + '반려 사유가 등록되면 여기에서 바로 확인하고 답글을 남길 수 있어요. 먼저 궁금한 점을 남겨도 좋아요.', + placeholder: '추가 자료나 설명을 남겨 재심사를 요청하세요', + }, +} + +/** 신청 1건의 단일 댓글 스레드 섹션 — 헤더 + 안내 + 말풍선 목록 + 입력바 */ +export function RequestThreadSection({ requestId, variant }: Props) { + const thread = useRequestCommentThreadViewModel(requestId, true) + const readOnly = variant === 'ACTIVATED' + + // 승인 완료(읽기 전용): 실제로 주고받은 메시지가 없을 때만 섹션 자체를 숨김. + // 로딩 중·에러 시에도 messageCount === 0 이므로, 이 두 경우까지 숨기지 않도록 + // 명시적으로 제외해 아래 로딩/에러 피드백(L66~88)이 노출되게 한다. + if ( + readOnly && + !thread.isLoading && + !thread.isError && + thread.messageCount === 0 + ) + return null + + const copy = readOnly ? null : VARIANT_COPY[variant] + const isEmpty = + !thread.isLoading && !thread.isError && thread.messageCount === 0 + const composerDisabled = thread.isLoading || thread.isError + + return ( +
+
+

스레드

+ + 메시지 {thread.messageCount}개 + +
+ + {variant === 'REVOKED' ? ( +

+ 댓글로 자료를 보강해{' '} + 재심사를 + 요청할 수 있어요. 관리자 첫 댓글이 곧 반려 사유예요. +

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

불러오는 중…

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

+ 문의 내용을 불러오지 못했어요 +

+

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

+
+ +
+ ) : null} + + {copy && isEmpty ? ( +
+

+ {copy.emptyTitle} +

+

+ {copy.emptyDescription} +

+
+ ) : null} + + {thread.messageCount > 0 ? ( +
+ {thread.comments.map(comment => ( + + ))} +
+ ) : null} + + {copy ? ( + + ) : null} +
+ ) +} diff --git a/src/features/store-register/ui/StoreBasicInfoFields.tsx b/src/features/store-register/ui/StoreBasicInfoFields.tsx index dfd8501c..ec25aa2f 100644 --- a/src/features/store-register/ui/StoreBasicInfoFields.tsx +++ b/src/features/store-register/ui/StoreBasicInfoFields.tsx @@ -1,7 +1,10 @@ +import type { ReactNode } from 'react' import { AuthInput } from '@/shared/ui/common/AuthInput' +import { AlertCircleIcon } from '@/features/store-register/ui/icons' type Props = { bizName: string + ownerName: string brn: string province: string district: string @@ -10,6 +13,7 @@ type Props = { type: string contact: string onBizNameChange: (v: string) => void + onOwnerNameChange: (v: string) => void onBrnChange: (v: string) => void onProvinceChange: (v: string) => void onDistrictChange: (v: string) => void @@ -19,8 +23,37 @@ type Props = { onContactChange: (v: string) => void } +/** 라벨 + 입력 한 묶음 */ +function Field({ + label, + hint, + htmlFor, + children, +}: { + label: string + hint?: string + htmlFor?: string + children: ReactNode +}) { + return ( +
+ + {children} + {hint ? ( +

{hint}

+ ) : null} +
+ ) +} + export function StoreBasicInfoFields({ bizName, + ownerName, brn, province, district, @@ -29,6 +62,7 @@ export function StoreBasicInfoFields({ type, contact, onBizNameChange, + onOwnerNameChange, onBrnChange, onProvinceChange, onDistrictChange, @@ -37,60 +71,113 @@ export function StoreBasicInfoFields({ onTypeChange, onContactChange, }: Props) { + const brnDigits = brn.replace(/\D/g, '').length + const brnError = brnDigits > 0 && brnDigits < 10 + return ( -
- onBizNameChange(e.target.value)} - autoComplete="organization" - /> - onBrnChange(e.target.value)} - inputMode="numeric" - /> - onTypeChange(e.target.value)} - /> - onContactChange(e.target.value)} - autoComplete="tel" - /> - onProvinceChange(e.target.value)} - /> - onDistrictChange(e.target.value)} - /> - onTownChange(e.target.value)} - /> - onAddressChange(e.target.value)} - autoComplete="street-address" - /> +
+ + onBizNameChange(e.target.value)} + autoComplete="organization" + /> + + + + onOwnerNameChange(e.target.value)} + autoComplete="name" + /> + + + + onBrnChange(e.target.value)} + inputMode="numeric" + maxLength={12} + /> + {brnError ? ( +

+ + 10자리 사업자등록번호를 정확히 입력해 주세요. +

+ ) : null} +
+ + + onTypeChange(e.target.value)} + /> + + + + onContactChange(e.target.value)} + inputMode="numeric" + maxLength={13} + autoComplete="tel" + /> + + + +
+
+ onProvinceChange(e.target.value)} + /> +
+
+ onDistrictChange(e.target.value)} + /> +
+
+ onTownChange(e.target.value)} + /> +
+
+ onAddressChange(e.target.value)} + autoComplete="street-address" + /> +
) } diff --git a/src/features/store-register/ui/StoreRequestListCard.tsx b/src/features/store-register/ui/StoreRequestListCard.tsx new file mode 100644 index 00000000..3544d731 --- /dev/null +++ b/src/features/store-register/ui/StoreRequestListCard.tsx @@ -0,0 +1,32 @@ +import { formatRequestDate } from '@/features/store-register/lib/formatDate' +import { StoreRequestStatusBadge } from '@/features/store-register/ui/StoreRequestStatusBadge' +import type { WorkspaceRequestListItemDto } from '@/features/store-register/types/workspaceRequests' + +type Props = { + item: WorkspaceRequestListItemDto + onClick: () => void +} + +/** 신청 내역 목록의 카드 한 건 */ +export function StoreRequestListCard({ item, onClick }: Props) { + return ( + + ) +} diff --git a/src/features/store-register/ui/StoreRequestStatusBadge.tsx b/src/features/store-register/ui/StoreRequestStatusBadge.tsx new file mode 100644 index 00000000..bacf442a --- /dev/null +++ b/src/features/store-register/ui/StoreRequestStatusBadge.tsx @@ -0,0 +1,17 @@ +import { resolveStatusBadge } from '@/features/store-register/lib/requestStatus' + +type Props = { + status: { value: string; description?: string } +} + +/** 신청 상태 배지 — 4상태(PENDING/ACTIVATED/REVOKED/CANCELED) */ +export function StoreRequestStatusBadge({ status }: Props) { + const { label, badgeClass } = resolveStatusBadge(status) + return ( + + {label} + + ) +} diff --git a/src/features/store-register/ui/icons.tsx b/src/features/store-register/ui/icons.tsx new file mode 100644 index 00000000..88d77847 --- /dev/null +++ b/src/features/store-register/ui/icons.tsx @@ -0,0 +1,208 @@ +/** + * 업장 등록 신청 플로우 전용 라인 아이콘 — viewBox 24, stroke 1.5px(currentColor). + * 색상은 부모의 text-* 클래스로 제어한다. + */ +import type { ReactNode } from 'react' + +type IconProps = { + width?: number + height?: number + className?: string +} + +function Svg({ + width = 24, + height = 24, + className, + children, +}: IconProps & { children: ReactNode }) { + return ( + + ) +} + +/** 업로드(트레이로 향하는 화살표) */ +export function UploadIcon(props: IconProps) { + return ( + + + + + ) +} + +/** 문서 */ +export function FileIcon(props: IconProps) { + return ( + + + + + ) +} + +/** 신분증 */ +export function IdCardIcon(props: IconProps) { + return ( + + + + + + ) +} + +/** 전송(종이비행기) */ +export function SendIcon(props: IconProps) { + return ( + + + + ) +} + +/** 클립(파일 첨부) */ +export function PaperclipIcon(props: IconProps) { + return ( + + + + ) +} + +/** 체크 표시가 든 원(승인·완료) */ +export function CheckCircleIcon(props: IconProps) { + return ( + + + + + ) +} + +/** 시계(검토 중·소요 안내) */ +export function ClockIcon(props: IconProps) { + return ( + + + + + ) +} + +/** 경고 삼각형(반려 사유) */ +export function WarningTriangleIcon(props: IconProps) { + return ( + + + + + ) +} + +/** X가 든 원(취소) */ +export function XCircleIcon(props: IconProps) { + return ( + + + + + ) +} + +/** 느낌표가 든 원(오류·검증) */ +export function AlertCircleIcon(props: IconProps) { + return ( + + + + + ) +} + +/** 새로고침(다시 시도) */ +export function RefreshIcon(props: IconProps) { + return ( + + + + ) +} + +/** 문서 + 플러스(빈 신청 내역) */ +export function DocPlusIcon(props: IconProps) { + return ( + + + + + + ) +} + +/** 닫기(파일 삭제) */ +export function CloseIcon(props: IconProps) { + return ( + + + + ) +} + +/** 더하기(새 신청) — 굵은 1.8 */ +export function PlusIcon({ width = 20, height = 20, className }: IconProps) { + return ( + + ) +} diff --git a/src/pages/manager/store-register/index.tsx b/src/pages/manager/store-register/index.tsx index 9ef9896a..770fd688 100644 --- a/src/pages/manager/store-register/index.tsx +++ b/src/pages/manager/store-register/index.tsx @@ -1,171 +1,187 @@ +import type { ReactNode } from 'react' import { Navbar } from '@/shared/ui/common/Navbar' -import { AuthButton } from '@/shared/ui/common/AuthButton' import { useStoreRegisterWizard } from '@/features/store-register/hooks/useStoreRegisterWizard' import { StoreBasicInfoFields } from '@/features/store-register/ui/StoreBasicInfoFields' import { CertificateUploader } from '@/features/store-register/ui/CertificateUploader' +import { + CheckCircleIcon, + ClockIcon, + IdCardIcon, +} from '@/features/store-register/ui/icons' + +/** ① 정보 ② 증빙 — 2단계 인디케이터 (완료 화면에서는 숨김) */ +function StepIndicator({ step }: { step: 'info' | 'certificate' }) { + const onInfo = step === 'info' + return ( +
+
    +
  1. + + ① 정보 + +
    +
  2. +
  3. + + ② 증빙 + +
  4. +
+
+ ) +} + +/** 위저드 공용 기본 CTA — 비활성 시 main 유지 + opacity 0.45 (디자인 토큰) */ +function PrimaryCta({ + children, + disabled, + onClick, +}: { + children: ReactNode + disabled?: boolean + onClick: () => void +}) { + return ( + + ) +} export function StoreRegisterPage() { const w = useStoreRegisterWizard() return (
- + -
-
    -
  1. - - ① 정보 - -
    -
  2. -
  3. - - ② 증빙 - -
  4. -
+ {w.step !== 'done' ? : null} +
{w.step === 'info' ? ( - <> -
-

- 업장 기본 정보 -

-

- 사업자 등록번호, 연락처와 주소(시도·구·동·상세 주소)를 입력해 - 주세요. 증명 서류를 올린 뒤 운영자 검토까지 1~2영업일이 걸릴 수 - 있어요. -

-
- -
- w.goCertificate()} - > - 다음 - -
- + ) : null} {w.step === 'certificate' ? ( - <> -
-

- 증빙 파일 -

-

- 각 서류를 선택하면 먼저 서버에 올린 뒤, 신청 정보와 함께 - 제출돼요. -

-
-
- - - +
+ + } + /> + +
+ + + 관리자 검토까지 영업일 3일 정도 걸려요. 결과는 푸시 알림으로 + 안내드려요. +
{w.submitError ? ( -

+

{w.submitError}

) : null} -
- w.submit()} - > - {w.isSubmitting ? '제출 중...' : '검토 요청 보내기'} - - -
- +
) : null} {w.step === 'done' ? ( - <> -
-

- 신청을 접수했어요 -

-

- 운영자 검토 후 승인되면 사장님 홈에서 매장이 연결돼요. -

-
- + + + +

+ 신청을 접수했어요 +

+

+ 관리자 검토 후 승인되면 사장님 계정으로 전환할 수 있어요. 영업일 + 3일 이내에 결과를 푸시 알림으로 알려드릴게요. +

+
+ ) : null} +
+ +
+ {w.step === 'info' ? ( + w.goCertificate()}> + 다음 + + ) : null} + + {w.step === 'certificate' ? ( +
+ w.submit()} + > + {w.isSubmitting ? ( + <> + + 제출 중... + + ) : ( + '검토 요청 보내기' + )} + + +
+ ) : null} + + {w.step === 'done' ? ( + w.goRequests()}>신청 내역 보기 ) : null}
diff --git a/src/pages/my/index.tsx b/src/pages/my/index.tsx index b46f5dbf..3abd7a3c 100644 --- a/src/pages/my/index.tsx +++ b/src/pages/my/index.tsx @@ -28,7 +28,7 @@ const MENU_ITEMS: MenuItem[] = [ key: 'store-apply', label: '업장 등록 신청', icon: StoreIcon, - path: '/my/store-apply', + path: ROUTES.STORE_REGISTER.REQUESTS, }, { key: 'support', @@ -136,6 +136,12 @@ export function MyPage() { ))} + {!isManager && ( +

+ 승인 완료 후 사장님 계정 전환이 활성화됩니다. +

+ )} +
+ ) : null} +
+ + {isCanceled ? ( + <> +
+ +

+ 취소된 신청입니다. 별도 조치가 필요하지 않아요. +

+
+
+ + +
+ + ) : ( + <> + + + + {statusValue === 'PENDING' ? ( +
+
+ +

+ 관리자가 검토 중이에요.{' '} + + 영업일 3일 이내 + + 에 결과를 푸시 알림으로 알려드릴게요. +

+
+ +
+ ) : null} + + {statusValue === 'REVOKED' ? ( + + ) : null} + + {statusValue === 'ACTIVATED' ? ( + + ) : null} + + )} + + {vm.cancelError ? ( +

+ {vm.cancelError} +

+ ) : null} +
+ ) : null} +
+ + +
+ ) +} + +export default StoreRegisterRequestDetailPage diff --git a/src/pages/store-register/requests/index.tsx b/src/pages/store-register/requests/index.tsx new file mode 100644 index 00000000..7e0dc29a --- /dev/null +++ b/src/pages/store-register/requests/index.tsx @@ -0,0 +1,105 @@ +import { useNavigate } from 'react-router-dom' +import { Navbar } from '@/shared/ui/common/Navbar' +import { AuthButton } from '@/shared/ui/common/AuthButton' +import { + ROUTES, + storeRegisterRequestDetailPath, +} from '@/shared/constants/routes' +import { useStoreRegisterRequestsViewModel } from '@/features/store-register/hooks/useStoreRegisterRequestsViewModel' +import { StoreRequestListCard } from '@/features/store-register/ui/StoreRequestListCard' +import { RequestListSkeleton } from '@/features/store-register/ui/RequestListSkeleton' +import { + AlertCircleIcon, + DocPlusIcon, + PlusIcon, + RefreshIcon, +} from '@/features/store-register/ui/icons' + +export function StoreRegisterRequestsPage() { + const navigate = useNavigate() + const { requests, isLoading, isError, refetch } = + useStoreRegisterRequestsViewModel() + + const goNewRequest = () => navigate(ROUTES.MANAGER.STORE_REGISTER) + const isEmpty = !isLoading && !isError && requests.length === 0 + const hasRequests = !isLoading && !isError && requests.length > 0 + + return ( +
+ + +
+ {isLoading ? : null} + + {isError ? ( +
+ + + +

+ 목록을 불러오지 못했어요 +

+

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

+ +
+ ) : null} + + {isEmpty ? ( +
+ + + +

+ 아직 등록 신청 내역이 없어요 +

+

+ 업장을 등록하고 관리자 승인 후 사장님 계정으로 전환해 보세요. +

+
+ ) : null} + + {hasRequests ? ( +
    + {requests.map(item => ( +
  • + + navigate(storeRegisterRequestDetailPath(item.id)) + } + /> +
  • + ))} +
+ ) : null} +
+ + {isEmpty || hasRequests ? ( +
+ + {hasRequests ? : null} + {hasRequests ? '새 업장 등록 신청' : '업장 등록 신청하기'} + +
+ ) : null} +
+ ) +} + +export default StoreRegisterRequestsPage diff --git a/src/shared/api/appFileUpload.ts b/src/shared/api/appFileUpload.ts index be9701e7..d30b73e9 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) */ @@ -10,12 +11,15 @@ export type AppFileUploadTargetType = | 'WORKSPACE_CERTIFICATE' | 'WORKSPACE_OWN_IDENTITY' | 'WORKSPACE_WARRANT' - | 'WORKSPACE_REASON_COMMENT' + | 'WORKSPACE_REQUEST_COMMENT' | 'CHAT_MESSAGE' 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, 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..e4cadb31 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -90,6 +90,14 @@ 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, + comments: (scope: 'MANAGER' | 'USER' | null, requestId: number) => + ['storeRegisterRequest', 'comments', scope, requestId] as const, + }, notification: { list: (scope: 'MANAGER' | 'USER' | null, type?: string) => ['notifications', scope, type] as const, diff --git a/tailwind.config.js b/tailwind.config.js index 6a9fc3a3..c0d6bc01 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -16,6 +16,11 @@ export default { colors: { // 오류 error: '#dc0000', + // 경고(검토 중 등) + warning: { + DEFAULT: '#e8920b', + 100: '#fdf3e2', + }, // 배경색 'bg-light': '#f4f4f4', 'bg-dark': '#efefef',