>
@@ -26,6 +33,7 @@ function DocContent({
isSelected: boolean
titleKey: TabKey
label?: string
+ badgeCount?: number
onClick: () => void
}) {
const Icon = icon
@@ -36,10 +44,17 @@ function DocContent({
className="flex min-w-0 flex-1 flex-col items-center gap-1 cursor-pointer h-[78px] pt-2.5 pb-3"
onClick={onClick}
>
-
+
+
+
+
>
+ /** 탭별 미읽음 뱃지 수 — 0·미지정이면 뱃지를 숨깁니다 */
+ badgeCountByTab?: Partial>
}
export function DocbarView({
@@ -70,12 +87,14 @@ export function DocbarView({
onTabClick,
tabs,
labelByTab,
+ badgeCountByTab,
}: DocbarViewProps) {
const iconByTab: Record>> = {
home: HomeIcon,
search: SearchIcon,
applicant: ApplicantIcon,
substitute: SubstituteIcon,
+ chat: ChatIcon,
my: MYIcon,
}
@@ -84,6 +103,7 @@ export function DocbarView({
search: 'Search',
applicant: 'Applicant',
substitute: 'Substitute',
+ chat: 'Chat',
my: 'MY',
}
@@ -98,6 +118,7 @@ export function DocbarView({
isSelected={selectedTab[tab]}
titleKey={tab}
label={labelByTab?.[tab]}
+ badgeCount={badgeCountByTab?.[tab]}
onClick={() => onTabClick(tab)}
/>
))}
@@ -114,6 +135,7 @@ export function Docbar() {
state => state.setSelectedTabByPathname
)
const { scope } = useAuthStore()
+ const chatUnreadCount = useChatUnreadStore(selectTotalChatUnread)
useEffect(() => {
setSelectedTabByPathname(pathname)
@@ -121,13 +143,10 @@ export function Docbar() {
const isManager = scope === 'MANAGER'
- /** 지원자 탭은 사장님 전용 — 일반 유저는 기존 4탭을 유지합니다 */
+ /** 채팅은 USER·MANAGER 공용 탭 — 사장님 지원자 목록은 내 공고 화면에서 진입합니다 */
const tabs = useMemo(
- () =>
- isManager
- ? ['home', 'search', 'applicant', 'substitute', 'my']
- : ['home', 'search', 'substitute', 'my'],
- [isManager]
+ () => ['home', 'search', 'substitute', 'chat', 'my'],
+ []
)
const pathByTab: Record = useMemo(
@@ -135,11 +154,12 @@ export function Docbar() {
home: homePathForScope(scope),
// 사장님은 구인구직(내 공고 목록), 일반 유저는 기존 알바찾기 경로 유지
search: isManager ? ROUTES.MANAGER.POSTINGS : ROUTES.USER.JOB_LOOKUP_MAP,
- // 사장님 전용 탭 — 일반 유저에게는 렌더링되지 않습니다
+ // 사장님 전용 — Docbar 에서는 제거됐고 내 공고 화면에서 진입합니다
applicant: ROUTES.MANAGER.POSTING_APPLICATIONS,
substitute: isManager
? ROUTES.MANAGER.SUBSTITUTE_REQUEST
: ROUTES.USER.SUBSTITUTE_REQUEST,
+ chat: ROUTES.CHAT.ROOMS,
my: ROUTES.MY.ROOT,
}),
[scope, isManager]
@@ -151,6 +171,11 @@ export function Docbar() {
[isManager]
)
+ const badgeCountByTab = useMemo>>(
+ () => ({ chat: chatUnreadCount }),
+ [chatUnreadCount]
+ )
+
const onTabClick = (tab: TabKey) => {
navigate(pathByTab[tab])
}
@@ -161,6 +186,7 @@ export function Docbar() {
onTabClick={onTabClick}
tabs={tabs}
labelByTab={labelByTab}
+ badgeCountByTab={badgeCountByTab}
/>
)
}
diff --git a/src/shared/ui/common/UnreadBadge.tsx b/src/shared/ui/common/UnreadBadge.tsx
new file mode 100644
index 00000000..dae64989
--- /dev/null
+++ b/src/shared/ui/common/UnreadBadge.tsx
@@ -0,0 +1,36 @@
+import { cn } from '@/shared/lib/utils'
+import { formatUnreadCount } from '@/shared/lib/unreadCount'
+
+interface UnreadBadgeProps {
+ count: number
+ /** 'md': 채팅 목록 행 · 'sm': Docbar·세그먼트 라벨 옆 */
+ size?: 'md' | 'sm'
+ className?: string
+}
+
+export function UnreadBadge({
+ count,
+ size = 'md',
+ className,
+}: UnreadBadgeProps) {
+ if (count <= 0) return null
+
+ const label = formatUnreadCount(count)
+
+ return (
+
+ {label}
+
+ )
+}
+
+export type { UnreadBadgeProps }
diff --git a/storybook/stories/Docbar.stories.tsx b/storybook/stories/Docbar.stories.tsx
index 35f3c844..c3b94fa8 100644
--- a/storybook/stories/Docbar.stories.tsx
+++ b/storybook/stories/Docbar.stories.tsx
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
+import { expect, within } from 'storybook/test'
import {
DocbarView,
@@ -12,16 +13,11 @@ const createSelectedTab = (activeTab: TabKey): DocbarSelectedTab => ({
search: activeTab === 'search',
substitute: activeTab === 'substitute',
applicant: activeTab === 'applicant',
+ chat: activeTab === 'chat',
})
-/** 사장님(MANAGER) 탭 구성 — 지원자 탭 포함 5탭 */
-const MANAGER_TABS: TabKey[] = [
- 'home',
- 'search',
- 'applicant',
- 'substitute',
- 'my',
-]
+/** 현재 Docbar 구성 — 채팅은 USER·MANAGER 공용 5탭 */
+const TABS: TabKey[] = ['home', 'search', 'substitute', 'chat', 'my']
const meta = {
title: 'shared/ui/common/Docbar',
@@ -30,58 +26,74 @@ const meta = {
layout: 'centered',
},
tags: ['autodocs'],
+ args: {
+ onTabClick: () => {},
+ tabs: TABS,
+ },
} satisfies Meta
export default meta
type Story = StoryObj
export const HomeSelected: Story = {
- args: {
- selectedTab: createSelectedTab('home'),
- onTabClick: () => {},
- tabs: ['home', 'search', 'substitute', 'my'],
- },
+ args: { selectedTab: createSelectedTab('home') },
}
export const SearchSelected: Story = {
- args: {
- selectedTab: createSelectedTab('search'),
- onTabClick: () => {},
- tabs: ['home', 'search', 'substitute', 'my'],
- },
+ args: { selectedTab: createSelectedTab('search') },
}
export const SubstituteSelected: Story = {
- args: {
- selectedTab: createSelectedTab('substitute'),
- onTabClick: () => {},
- tabs: ['home', 'search', 'substitute', 'my'],
- },
+ args: { selectedTab: createSelectedTab('substitute') },
}
export const MySelected: Story = {
+ args: { selectedTab: createSelectedTab('my') },
+}
+
+export const ChatSelected: Story = {
+ args: { selectedTab: createSelectedTab('chat') },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+
+ await expect(canvas.getByText('채팅')).toBeVisible()
+ },
+}
+
+/** 채팅 뱃지는 개인+전체 미읽음 합산값입니다 */
+export const ChatWithUnreadBadge: Story = {
args: {
- selectedTab: createSelectedTab('my'),
- onTabClick: () => {},
- tabs: ['home', 'search', 'substitute', 'my'],
+ selectedTab: createSelectedTab('home'),
+ badgeCountByTab: { chat: 4 },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+
+ await expect(canvas.getByLabelText('읽지 않은 메시지 4개')).toBeVisible()
},
}
-/** 사장님 5탭 — '알바 찾기'가 '내 공고'로 노출되고 지원자 탭이 추가됩니다 */
-export const ManagerApplicantSelected: Story = {
+export const ChatWithOverflowBadge: Story = {
args: {
- selectedTab: createSelectedTab('applicant'),
- onTabClick: () => {},
- tabs: MANAGER_TABS,
- labelByTab: { search: '내 공고' },
+ selectedTab: createSelectedTab('home'),
+ badgeCountByTab: { chat: 150 },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+
+ await expect(canvas.getByText('99+')).toBeVisible()
},
}
+/** 사장님은 '알바 찾기'가 '내 공고'로 노출됩니다 */
export const ManagerPostingsSelected: Story = {
args: {
selectedTab: createSelectedTab('search'),
- onTabClick: () => {},
- tabs: MANAGER_TABS,
labelByTab: { search: '내 공고' },
},
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+
+ await expect(canvas.getByText('내 공고')).toBeVisible()
+ },
}
diff --git a/storybook/stories/SocialList.stories.tsx b/storybook/stories/SocialList.stories.tsx
deleted file mode 100644
index baea2bc0..00000000
--- a/storybook/stories/SocialList.stories.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/react-vite'
-import React from 'react'
-
-import { SocialList } from '../../src/features/social/ui/SocialList'
-
-const meta = {
- title: 'shared/ui/social/SocialList',
- component: SocialList,
- parameters: { layout: 'centered' },
- tags: ['autodocs'],
- decorators: [
- Story => (
-
-
-
- ),
- ],
- argTypes: {
- unread: { control: 'boolean' },
- },
-} satisfies Meta
-
-export default meta
-type Story = StoryObj
-
-export const Default: Story = {
- args: {
- name: '홍길동',
- message: '오늘 스케줄 확인 부탁드려요.',
- timeAgo: '5분 전',
- unread: false,
- },
-}
-
-export const Unread: Story = {
- args: {
- name: '김철수',
- message: '내일 대타 가능하신가요?',
- timeAgo: '1시간 전',
- unread: true,
- },
-}
-
-export const LongMessage: Story = {
- args: {
- name: '매장 매니저',
- message:
- '이번 주 금요일 야간 근무 인원이 부족해서 혹시 가능하시면 연락 주시면 감사하겠습니다.',
- timeAgo: '어제',
- unread: true,
- },
-}