Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"**/*.{js,jsx,json,css}": "prettier --write"
},
"dependencies": {
"@stomp/stompjs": "^7.3.0",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"clsx": "^2.1.1",
Expand Down
21 changes: 14 additions & 7 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {
import { ManagerHomePage } from '@/pages/manager/home'
import { ManagerWorkerSchedulePage } from '@/pages/manager/worker-schedule'
import { ManagerWorkerScheduleLegacyEntryRedirect } from '@/pages/manager/worker-schedule/LegacyEntryRedirect'
import { SocialPage } from '@/pages/manager/social'
import { SocialChatPage } from '@/pages/manager/social-chat'
import { ChatRoomsPage } from '@/pages/chat/rooms'
import { ChatRoomPage } from '@/pages/chat/room'
import { LoginPage } from '@/pages/login'
import { FindPasswordPage } from '@/pages/find-password'
import { KakaoCallbackPage } from '@/pages/oauth/KakaoCallbackPage'
Expand Down Expand Up @@ -169,6 +169,17 @@ export function App() {
path={ROUTES.MANAGER.WORKER_INVITE}
element={<ManagerWorkerInvitePage />}
/>
{/* 채팅방은 하단 입력바를 쓰므로 Docbar 없이 표시합니다 */}
<Route path={ROUTES.CHAT.ROOM_PATTERN} element={<ChatRoomPage />} />
{/* 구 사장님 전용 채팅 경로 — 공용 채팅으로 통합 */}
<Route
path={ROUTES.MANAGER.SOCIAL}
element={<Navigate to={ROUTES.CHAT.ROOMS} replace />}
/>
<Route
path={ROUTES.MANAGER.SOCIAL_CHAT}
element={<Navigate to={ROUTES.CHAT.ROOMS} replace />}
/>
<Route
path={ROUTES.MANAGER.WORKSPACE_IMAGES_EDIT_PATTERN}
element={<WorkspaceImageEditPage />}
Expand Down Expand Up @@ -253,11 +264,7 @@ export function App() {
</HomeRouteGuard>
}
/>
<Route path={ROUTES.MANAGER.SOCIAL} element={<SocialPage />} />
<Route
path={ROUTES.MANAGER.SOCIAL_CHAT}
element={<SocialChatPage />}
/>
<Route path={ROUTES.CHAT.ROOMS} element={<ChatRoomsPage />} />
<Route
path={ROUTES.MANAGER.POSTINGS}
element={
Expand Down
3 changes: 3 additions & 0 deletions src/assets/icons/doc/Chat.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
150 changes: 150 additions & 0 deletions src/features/chat/api/chatContacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import axiosInstance from '@/shared/lib/axiosInstance'
import type { CommonApiResponse } from '@/shared/types/common'
import type { ChatContact } from '@/features/chat/types/chat'
import type { ChatApiScope } from '@/features/chat/api/chatRoom'

/**
* 새 채팅 상대 후보 조회.
* 채팅 전용 엔드포인트가 아직 없어 기존 업장 멤버 API를 스코프별로 조합합니다.
* - USER: 같은 근무지의 동료(알바생) + 점주
* - MANAGER: 소속 업장의 알바생
*/

const MEMBER_PAGE_SIZE = 100

/** 재직 중인 근무자 — 매니저 홈 목록(`useManagerHomeViewModel`)과 같은 값을 씁니다 */
const WORKER_STATUS_ACTIVE = 'ACTIVATED'

interface CursorBody<T> {
page: { cursor: string | null; pageSize: number; totalCount: number }
data: T[]
}

interface MemberPersonDto {
id: number
name: string
profileImageUrl?: string | null
}

interface UserWorkspaceItemDto {
workspaceId: number
businessName: string
}

interface ManagerWorkspaceItemDto {
id: number
businessName: string
}

function toContact(
person: MemberPersonDto,
scope: 'USER' | 'MANAGER',
workspaceName: string
): ChatContact {
return {
key: `${scope}:${person.id}`,
id: person.id,
scope,
name: person.name,
profileImageUrl: person.profileImageUrl ?? null,
workspaceName,
}
}

/**
* 업장별 조회를 병렬로 돌리되 일부 실패는 건너뜁니다.
* `Promise.all` 이면 업장 하나가 4xx 를 뱉을 때 연락처 목록 전체가 빈 화면이 됩니다.
*/
async function settleWorkspaceContacts(
tasks: Promise<ChatContact[]>[]
): Promise<ChatContact[]> {
const results = await Promise.allSettled(tasks)
const succeeded = results.filter(
(result): result is PromiseFulfilledResult<ChatContact[]> =>
result.status === 'fulfilled'
)

// 전부 실패했다면 진짜 장애이므로 첫 에러를 그대로 올려 에러 화면을 띄웁니다
if (succeeded.length === 0 && results.length > 0) {
throw (results[0] as PromiseRejectedResult).reason
}

return succeeded.flatMap(result => result.value)
}

/** 중복 인물(여러 업장에 함께 근무)은 첫 업장 기준으로 한 번만 노출합니다 */
function dedupeContacts(contacts: ChatContact[]): ChatContact[] {
const seen = new Set<string>()
return contacts.filter(contact => {
if (seen.has(contact.key)) return false
seen.add(contact.key)
return true
})
}

async function fetchUserContacts(): Promise<ChatContact[]> {
const workspacesResponse = await axiosInstance.get<
CommonApiResponse<CursorBody<UserWorkspaceItemDto>>
>('/app/users/me/workspaces', { params: { pageSize: MEMBER_PAGE_SIZE } })

const workspaces = workspacesResponse.data.data.data

const perWorkspace = await settleWorkspaceContacts(
workspaces.map(async workspace => {
const [workers, managers] = await Promise.all([
axiosInstance.get<
CommonApiResponse<CursorBody<{ user: MemberPersonDto }>>
>(`/app/users/me/workspaces/${workspace.workspaceId}/workers`, {
params: { pageSize: MEMBER_PAGE_SIZE },
}),
axiosInstance.get<
CommonApiResponse<CursorBody<{ manager: MemberPersonDto }>>
>(`/app/users/me/workspaces/${workspace.workspaceId}/managers`, {
params: { pageSize: MEMBER_PAGE_SIZE },
}),
])

return [
...managers.data.data.data.map(item =>
toContact(item.manager, 'MANAGER', workspace.businessName)
),
...workers.data.data.data.map(item =>
toContact(item.user, 'USER', workspace.businessName)
),
]
})
)

return dedupeContacts(perWorkspace)
}

async function fetchManagerContacts(): Promise<ChatContact[]> {
const workspacesResponse = await axiosInstance.get<
CommonApiResponse<ManagerWorkspaceItemDto[]>
>('/manager/workspaces')

const workspaces = workspacesResponse.data.data

const perWorkspace = await settleWorkspaceContacts(
workspaces.map(async workspace => {
const workers = await axiosInstance.get<
CommonApiResponse<CursorBody<{ user: MemberPersonDto }>>
>(`/manager/workspaces/${workspace.id}/workers`, {
// 퇴사자 제외 — 서버 enum 은 EMPLOYED 가 아니라 ACTIVATED 입니다
params: { pageSize: MEMBER_PAGE_SIZE, status: WORKER_STATUS_ACTIVE },
})

return workers.data.data.data.map(item =>
toContact(item.user, 'USER', workspace.businessName)
)
})
)

return dedupeContacts(perWorkspace)
}

export async function fetchChatContacts(
scope: ChatApiScope
): Promise<ChatContact[]> {
return scope === 'MANAGER' ? fetchManagerContacts() : fetchUserContacts()
}
104 changes: 104 additions & 0 deletions src/features/chat/api/chatRoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import axiosInstance from '@/shared/lib/axiosInstance'
import { getAuthApiBasePath } from '@/shared/lib/authApiPath'
import { unwrapCursorPage, type CursorPage } from '@/shared/lib/cursorPage'
import type { CommonApiResponse } from '@/shared/types/common'
import type {
ChatMessageDto,
ChatRoomDetailDto,
ChatRoomListItemDto,
CreateChatRoomRequest,
CreateChatRoomResponseDto,
MarkChatRoomReadRequest,
} from '@/features/chat/types/dto'

export type ChatApiScope = 'MANAGER' | 'USER' | null | undefined

/** `/app/chat` | `/manager/chat` */
function chatBasePath(scope: ChatApiScope): string {
return `/${getAuthApiBasePath(scope)}/chat`
}

/** 채팅 엔드포인트는 CommonApiResponse 로 감싼 응답과 날 응답이 섞여 있어 둘 다 받습니다 */
function unwrapData<T extends object>(body: CommonApiResponse<T> | T): T {
return 'data' in body ? body.data : body
}

export interface ChatRoomsQueryParams {
pageSize: number
cursor?: string
}

/** GET /{app|manager}/chat/rooms */
export async function fetchChatRooms(
scope: ChatApiScope,
params: ChatRoomsQueryParams
): Promise<CursorPage<ChatRoomListItemDto>> {
const response = await axiosInstance.get<
| CursorPage<ChatRoomListItemDto>
| CommonApiResponse<CursorPage<ChatRoomListItemDto>>
>(`${chatBasePath(scope)}/rooms`, {
params: {
pageSize: params.pageSize,
...(params.cursor !== undefined && { cursor: params.cursor }),
},
})
return unwrapCursorPage(response.data)
}

export interface ChatMessagesQueryParams {
pageSize: number
/** 위로 당겨 과거 메시지를 가져올 때의 커서 */
cursor?: string
}

/** GET /{app|manager}/chat/rooms/{roomId}/messages */
export async function fetchChatMessages(
scope: ChatApiScope,
roomId: number,
params: ChatMessagesQueryParams
): Promise<CursorPage<ChatMessageDto>> {
const response = await axiosInstance.get<
CursorPage<ChatMessageDto> | CommonApiResponse<CursorPage<ChatMessageDto>>
>(`${chatBasePath(scope)}/rooms/${roomId}/messages`, {
params: {
pageSize: params.pageSize,
...(params.cursor !== undefined && { cursor: params.cursor }),
},
})
return unwrapCursorPage(response.data)
}

/** GET /{app|manager}/chat/rooms/{roomId} — 목록 캐시 없이 진입했을 때의 방 정보 */
export async function fetchChatRoomDetail(
scope: ChatApiScope,
roomId: number
): Promise<ChatRoomDetailDto> {
const response = await axiosInstance.get<
CommonApiResponse<ChatRoomDetailDto> | ChatRoomDetailDto
>(`${chatBasePath(scope)}/rooms/${roomId}`)
return unwrapData(response.data)
}

/**
* POST /{app|manager}/chat/rooms/{roomId}/read
* 서버가 last_read 를 갱신하므로 어디까지 읽었는지 함께 보냅니다. 멱등·clamp 처리됩니다.
*/
export async function markChatRoomRead(
scope: ChatApiScope,
roomId: number,
lastReadMessageId: number
): Promise<void> {
const body: MarkChatRoomReadRequest = { lastReadMessageId }
await axiosInstance.post(`${chatBasePath(scope)}/rooms/${roomId}/read`, body)
}

/** POST /{app|manager}/chat/rooms — 이미 방이 있으면 서버가 기존 방을 반환합니다 */
export async function createChatRoom(
scope: ChatApiScope,
body: CreateChatRoomRequest
): Promise<CreateChatRoomResponseDto> {
const response = await axiosInstance.post<
CommonApiResponse<CreateChatRoomResponseDto> | CreateChatRoomResponseDto
>(`${chatBasePath(scope)}/rooms`, body)
return unwrapData(response.data)
}
27 changes: 27 additions & 0 deletions src/features/chat/hooks/mutation/useCreateChatRoomMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createChatRoom } from '@/features/chat/api/chatRoom'
import { toServerScope } from '@/features/chat/lib/adaptChat'
import type { ChatParticipantScope } from '@/features/chat/types/dto'
import { queryKeys } from '@/shared/lib/queryKeys'
import useAuthStore from '@/shared/stores/useAuthStore'

export interface CreateChatRoomVariables {
opponentId: number
opponentScope: ChatParticipantScope
}

export function useCreateChatRoomMutation() {
const queryClient = useQueryClient()
const scope = useAuthStore(state => state.scope)

return useMutation({
mutationFn: (variables: CreateChatRoomVariables) =>
createChatRoom(scope, {
opponentUserId: variables.opponentId,
opponentScope: toServerScope(variables.opponentScope),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.chat.all })
},
})
}
24 changes: 24 additions & 0 deletions src/features/chat/hooks/mutation/useMarkChatRoomReadMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { markChatRoomRead } from '@/features/chat/api/chatRoom'
import { queryKeys } from '@/shared/lib/queryKeys'
import useAuthStore from '@/shared/stores/useAuthStore'

export interface MarkChatRoomReadVariables {
roomId: number
/** 서버가 last_read 를 이 지점까지 올립니다 */
lastReadMessageId: number
}

export function useMarkChatRoomReadMutation() {
const queryClient = useQueryClient()
const scope = useAuthStore(state => state.scope)

return useMutation({
mutationFn: ({ roomId, lastReadMessageId }: MarkChatRoomReadVariables) =>
markChatRoomRead(scope, roomId, lastReadMessageId),
onSuccess: () => {
// 방 진입으로 미읽음이 0이 되므로 목록·Docbar 뱃지를 갱신합니다
void queryClient.invalidateQueries({ queryKey: queryKeys.chat.roomsAll })
},
})
}
Loading
Loading