feat(WEB): 헤더 알림 드롭다운 TodayIssue API 연동
- TodayIssue 타입 정의 파일 생성 (src/types/today-issue.ts) - TodayIssue API 서비스 함수 생성 (src/lib/api/today-issue.ts) - getUnreadTodayIssues: 읽지 않은 알림 목록 조회 - markTodayIssueAsRead: 개별 읽음 처리 - markAllTodayIssuesAsRead: 전체 읽음 처리 - AuthenticatedLayout 알림 드롭다운 API 연동 - MOCK_NOTIFICATIONS 제거, 실제 API 연동 - 30초 폴링으로 알림 데이터 갱신 - 알림 클릭 시 읽음 처리 + 페이지 이동 - 모두 읽음 버튼 기능 구현 - 벨 애니메이션 (읽지 않은 알림 있을 때만)
This commit is contained in:
@@ -4,7 +4,7 @@ import { useMenuStore } from '@/store/menuStore';
|
||||
import type { SerializableMenuItem } from '@/store/menuStore';
|
||||
import type { MenuItem } from '@/store/menuStore';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Menu,
|
||||
Search,
|
||||
@@ -50,6 +50,14 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
// TodayIssue 타입 및 API
|
||||
import type { TodayIssueUnreadItem } from '@/types/today-issue';
|
||||
import {
|
||||
getUnreadTodayIssues,
|
||||
markTodayIssueAsRead,
|
||||
markAllTodayIssuesAsRead,
|
||||
} from '@/lib/api/today-issue';
|
||||
|
||||
// 목업 회사 데이터
|
||||
const MOCK_COMPANIES = [
|
||||
{ id: 'all', name: '전체' },
|
||||
@@ -59,12 +67,8 @@ const MOCK_COMPANIES = [
|
||||
{ id: 'company4', name: 'GS건설(주)' },
|
||||
];
|
||||
|
||||
// 목업 알림 데이터
|
||||
const MOCK_NOTIFICATIONS = [
|
||||
{ id: 1, category: '안내', title: '시스템 점검 안내', date: '2025.09.03 12:23', isNew: true },
|
||||
{ id: 2, category: '공지사항', title: '신규 기능 업데이트', date: '2025.09.03 12:23', isNew: false },
|
||||
{ id: 3, category: '안내', title: '보안 업데이트 완료', date: '2025.09.03 12:23', isNew: false },
|
||||
];
|
||||
// 알림 폴링 간격 (30초)
|
||||
const NOTIFICATION_POLLING_INTERVAL = 30000;
|
||||
|
||||
interface AuthenticatedLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -93,8 +97,13 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
// 회사 선택 상태 (목업)
|
||||
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
||||
|
||||
// 알림 벨 애니메이션 상태 (클릭으로 토글)
|
||||
const [bellAnimating, setBellAnimating] = useState(false);
|
||||
// 알림 관련 상태
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
||||
const [notifications, setNotifications] = useState<TodayIssueUnreadItem[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
// 알림 벨 애니메이션 (알림 켜져 있고 읽지 않은 알림이 있을 때만)
|
||||
const bellAnimating = useMemo(() => notificationEnabled && unreadCount > 0, [notificationEnabled, unreadCount]);
|
||||
|
||||
// 🔧 클라이언트 마운트 상태 (새로고침 시 스피너 표시용)
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
@@ -104,6 +113,67 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// 알림 데이터 가져오기 함수
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
try {
|
||||
const response = await getUnreadTodayIssues(10);
|
||||
if (response.success && response.data) {
|
||||
setNotifications(response.data.items);
|
||||
setUnreadCount(response.data.total);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Notification] 알림 조회 실패:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 알림 폴링 (30초마다)
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
// 초기 로드
|
||||
fetchNotifications();
|
||||
|
||||
// 폴링 시작
|
||||
const intervalId = setInterval(fetchNotifications, NOTIFICATION_POLLING_INTERVAL);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [isMounted, fetchNotifications]);
|
||||
|
||||
// 알림 클릭 핸들러 (읽음 처리 + 페이지 이동)
|
||||
const handleNotificationClick = useCallback(async (notification: TodayIssueUnreadItem) => {
|
||||
try {
|
||||
// 읽음 처리
|
||||
await markTodayIssueAsRead(notification.id);
|
||||
|
||||
// 로컬 상태 업데이트 (해당 알림 제거)
|
||||
setNotifications(prev => prev.filter(n => n.id !== notification.id));
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
|
||||
// 페이지 이동 (path가 있으면)
|
||||
if (notification.path) {
|
||||
router.push(notification.path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Notification] 읽음 처리 실패:', error);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// 모두 읽음 처리 핸들러
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
try {
|
||||
const response = await markAllTodayIssuesAsRead();
|
||||
if (response.success) {
|
||||
// 로컬 상태 초기화
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Notification] 모두 읽음 처리 실패:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 메뉴 폴링 (30초마다 메뉴 변경 확인)
|
||||
// 백엔드 GET /api/v1/menus API 준비되면 자동 동작
|
||||
const { restartAfterAuth } = useMenuPolling({
|
||||
@@ -470,54 +540,84 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
>
|
||||
<Bell className={`h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5 text-amber-600 ${bellAnimating ? 'animate-bell-ring' : ''}`} />
|
||||
{/* 새 알림 표시 */}
|
||||
{MOCK_NOTIFICATIONS.some(n => n.isNew) && (
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 min-[320px]:top-1.5 min-[320px]:right-1.5 sm:top-2 sm:right-2 w-1 h-1 min-[320px]:w-1.5 min-[320px]:h-1.5 sm:w-2 sm:h-2 bg-red-500 rounded-full" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||
{/* 헤더: 알림 + 애니메이션 토글 */}
|
||||
{/* 헤더: 알림 토글 + 모두 읽음 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted">
|
||||
<span className="font-semibold text-foreground">알림</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setBellAnimating(!bellAnimating);
|
||||
}}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{bellAnimating ? '🔔 알림 끄기' : '🔕 알림 켜기'}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground">알림</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">({unreadCount})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{notifications.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleMarkAllAsRead();
|
||||
}}
|
||||
className="h-7 px-2 text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
모두 읽음
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setNotificationEnabled(!notificationEnabled);
|
||||
}}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{notificationEnabled ? '🔔' : '🔕'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 알림 리스트 - 3개 초과시 스크롤 */}
|
||||
<div className="max-h-[216px] overflow-y-auto">
|
||||
{MOCK_NOTIFICATIONS.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer"
|
||||
>
|
||||
{/* 이미지 플레이스홀더 */}
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-muted border border-border rounded-lg flex items-center justify-center text-muted-foreground text-xs">
|
||||
IMG
|
||||
</div>
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-xs">{notification.category}</span>
|
||||
{notification.isNew && (
|
||||
<span className="w-4 h-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold">
|
||||
N
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-foreground font-medium text-sm mt-1 truncate">{notification.title}</p>
|
||||
</div>
|
||||
{/* 날짜 */}
|
||||
<span className="flex-shrink-0 text-muted-foreground text-xs">{notification.date}</span>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-6 text-center text-muted-foreground text-sm">
|
||||
새로운 알림이 없습니다
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer"
|
||||
>
|
||||
{/* 배지 */}
|
||||
<div className="flex-shrink-0">
|
||||
<span className={`inline-flex items-center justify-center px-2 py-1 text-xs font-medium rounded-md ${
|
||||
notification.needs_approval
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{notification.badge}
|
||||
</span>
|
||||
</div>
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-foreground font-medium text-sm truncate">{notification.content}</p>
|
||||
<span className="text-muted-foreground text-xs">{notification.time}</span>
|
||||
</div>
|
||||
{/* 승인 필요 표시 */}
|
||||
{notification.needs_approval && (
|
||||
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold">
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -696,54 +796,84 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
<Bell className={`text-amber-500 ${bellAnimating ? 'animate-bell-ring' : ''}`} style={{ width: 23, height: 23 }} />
|
||||
</div>
|
||||
{/* 새 알림 표시 */}
|
||||
{MOCK_NOTIFICATIONS.some(n => n.isNew) && (
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-0 right-0 w-3.5 h-3.5 bg-red-500 rounded-full border-2 border-background" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||
{/* 헤더: 알림 + 애니메이션 토글 */}
|
||||
{/* 헤더: 알림 토글 + 모두 읽음 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted">
|
||||
<span className="font-semibold text-foreground">알림</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setBellAnimating(!bellAnimating);
|
||||
}}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{bellAnimating ? '🔔 알림 끄기' : '🔕 알림 켜기'}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground">알림</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">({unreadCount})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{notifications.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleMarkAllAsRead();
|
||||
}}
|
||||
className="h-7 px-2 text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
모두 읽음
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setNotificationEnabled(!notificationEnabled);
|
||||
}}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{notificationEnabled ? '🔔' : '🔕'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 알림 리스트 - 3개 초과시 스크롤 */}
|
||||
<div className="max-h-[216px] overflow-y-auto">
|
||||
{MOCK_NOTIFICATIONS.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer"
|
||||
>
|
||||
{/* 이미지 플레이스홀더 */}
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-muted border border-border rounded-lg flex items-center justify-center text-muted-foreground text-xs">
|
||||
IMG
|
||||
</div>
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-xs">{notification.category}</span>
|
||||
{notification.isNew && (
|
||||
<span className="w-4 h-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold">
|
||||
N
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-foreground font-medium text-sm mt-1 truncate">{notification.title}</p>
|
||||
</div>
|
||||
{/* 날짜 */}
|
||||
<span className="flex-shrink-0 text-muted-foreground text-xs">{notification.date}</span>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-6 text-center text-muted-foreground text-sm">
|
||||
새로운 알림이 없습니다
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer"
|
||||
>
|
||||
{/* 배지 */}
|
||||
<div className="flex-shrink-0">
|
||||
<span className={`inline-flex items-center justify-center px-2 py-1 text-xs font-medium rounded-md ${
|
||||
notification.needs_approval
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{notification.badge}
|
||||
</span>
|
||||
</div>
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-foreground font-medium text-sm truncate">{notification.content}</p>
|
||||
<span className="text-muted-foreground text-xs">{notification.time}</span>
|
||||
</div>
|
||||
{/* 승인 필요 표시 */}
|
||||
{notification.needs_approval && (
|
||||
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold">
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
94
src/lib/api/today-issue.ts
Normal file
94
src/lib/api/today-issue.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* TodayIssue API 서비스
|
||||
* 헤더 알림 기능을 위한 API 호출 함수
|
||||
*/
|
||||
|
||||
import { apiClient } from './index';
|
||||
import type {
|
||||
ApiResponse,
|
||||
TodayIssueUnreadResponse,
|
||||
TodayIssueUnreadCountResponse,
|
||||
TodayIssueMarkAllReadResponse,
|
||||
} from '@/types/today-issue';
|
||||
|
||||
/**
|
||||
* 읽지 않은 이슈 목록 조회 (헤더 알림용)
|
||||
* @param limit 조회할 최대 항목 수 (기본 10)
|
||||
*/
|
||||
export async function getUnreadTodayIssues(limit: number = 10): Promise<ApiResponse<TodayIssueUnreadResponse>> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<TodayIssueUnreadResponse>>(
|
||||
'/today-issues/unread',
|
||||
{ params: { limit: String(limit) } }
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[TodayIssue] getUnreadTodayIssues error:', error);
|
||||
// 에러 시 빈 응답 반환 (UI에서 처리)
|
||||
return {
|
||||
success: false,
|
||||
message: '알림을 불러오는데 실패했습니다.',
|
||||
data: { items: [], total: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 읽지 않은 이슈 개수 조회 (헤더 뱃지용)
|
||||
*/
|
||||
export async function getUnreadTodayIssueCount(): Promise<ApiResponse<TodayIssueUnreadCountResponse>> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<TodayIssueUnreadCountResponse>>(
|
||||
'/today-issues/unread/count'
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[TodayIssue] getUnreadTodayIssueCount error:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: '알림 개수를 불러오는데 실패했습니다.',
|
||||
data: { count: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이슈 읽음 처리
|
||||
* @param id 이슈 ID
|
||||
*/
|
||||
export async function markTodayIssueAsRead(id: number): Promise<ApiResponse<null>> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<null>>(
|
||||
`/today-issues/${id}/read`
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[TodayIssue] markTodayIssueAsRead error:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: '읽음 처리에 실패했습니다.',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 이슈 읽음 처리
|
||||
*/
|
||||
export async function markAllTodayIssuesAsRead(): Promise<ApiResponse<TodayIssueMarkAllReadResponse>> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<TodayIssueMarkAllReadResponse>>(
|
||||
'/today-issues/read-all'
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[TodayIssue] markAllTodayIssuesAsRead error:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: '모두 읽음 처리에 실패했습니다.',
|
||||
data: { count: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
71
src/types/today-issue.ts
Normal file
71
src/types/today-issue.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* TodayIssue 관련 타입 정의
|
||||
* API: api/app/Swagger/v1/TodayIssueApi.php 참조
|
||||
*/
|
||||
|
||||
/** 알림 뱃지 타입 (헤더 알림용) */
|
||||
export type TodayIssueBadge =
|
||||
| '수주등록'
|
||||
| '추심이슈'
|
||||
| '안전재고'
|
||||
| '지출 승인대기'
|
||||
| '세금 신고'
|
||||
| '결재 요청'
|
||||
| '신규거래처';
|
||||
|
||||
/** 알림 설정 타입 */
|
||||
export type NotificationType =
|
||||
| 'sales_order' // 수주등록
|
||||
| 'new_vendor' // 신규거래처
|
||||
| 'approval_request' // 결재 요청
|
||||
| 'bad_debt' // 추심이슈
|
||||
| 'safety_stock' // 안전재고
|
||||
| 'expected_expense' // 지출 승인대기
|
||||
| 'vat_report'; // 세금 신고
|
||||
|
||||
/**
|
||||
* 읽지 않은 이슈 항목 (헤더 알림용)
|
||||
* TodayIssueUnreadItem 스키마
|
||||
*/
|
||||
export interface TodayIssueUnreadItem {
|
||||
id: number;
|
||||
badge: TodayIssueBadge;
|
||||
notification_type: NotificationType;
|
||||
content: string;
|
||||
path: string | null;
|
||||
needs_approval: boolean;
|
||||
time: string;
|
||||
created_at: string; // ISO 8601
|
||||
}
|
||||
|
||||
/**
|
||||
* 읽지 않은 이슈 목록 응답
|
||||
* TodayIssueUnreadResponse 스키마
|
||||
*/
|
||||
export interface TodayIssueUnreadResponse {
|
||||
items: TodayIssueUnreadItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 읽지 않은 이슈 개수 응답
|
||||
* TodayIssueUnreadCountResponse 스키마
|
||||
*/
|
||||
export interface TodayIssueUnreadCountResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 이슈 읽음 처리 응답
|
||||
* TodayIssueMarkAllReadResponse 스키마
|
||||
*/
|
||||
export interface TodayIssueMarkAllReadResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** API 응답 공통 래퍼 */
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
Reference in New Issue
Block a user