[common/components] feat: 알림 기능 추가

This commit is contained in:
2025-08-07 09:55:41 +09:00
parent 3c76b3ae0b
commit 7e1f40147e
6 changed files with 269 additions and 50 deletions
@@ -134,7 +134,7 @@ function DashboardPage() {
{/* 전체 게시물 카드 */}
<div className="card card-border h-80 !p-0">
<div className="relative h-full">
<div className="absolute left-0 top-0 p-6 tracking-tighter">
<div className="absolute left-0 top-0 p-6 tracking-tighter text-foreground">
<h3 className="text-2xl font-medium">전체 게시물</h3>
<div className="mt-2 text-6xl font-bold">
{data?.pstTotCnt ? data.pstTotCnt.toLocaleString() : "-"}
@@ -157,7 +157,7 @@ function DashboardPage() {
{/* 오늘 접속자 수 카드 */}
<div className="card card-border h-80 !p-0">
<div className="relative h-full">
<div className="absolute left-0 top-0 p-6 tracking-tighter">
<div className="absolute left-0 top-0 p-6 tracking-tighter text-foreground">
<h3 className="text-2xl font-medium">오늘 접속자 수</h3>
<div className="mt-2 text-6xl font-bold">
{data?.accessTodayCnt
@@ -184,7 +184,7 @@ function DashboardPage() {
{/* API 호출 건수 카드 */}
<div className="card card-border h-80 !p-0">
<div className="relative h-full">
<div className="absolute left-0 top-0 p-6 tracking-tighter">
<div className="absolute left-0 top-0 p-6 tracking-tighter text-foreground">
<h3 className="text-2xl font-medium">API 호출 건수</h3>
<div className="mt-2 text-6xl font-bold">
{data?.apiTodayCnt ? data.apiTodayCnt.toLocaleString() : "-"}
@@ -206,13 +206,13 @@ function DashboardPage() {
<div className="grid grid-cols-2 gap-1">
{/* 승인 대기 알림 */}
<div className="card card-border">
<div className="card-header">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">승인 대기 알림</h3>
</div>
<div className="p-4">
<div className="flex gap-4">
{/* 사용자 승인 */}
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6 text-foreground">
<div className="flex items-center">
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
<svg
@@ -240,13 +240,13 @@ function DashboardPage() {
: "0"}
건
</div>
<div className="mt-2 text-2xl text-gray-500">
<div className="mt-2 text-2xl opacity-50">
신규 가입 승인 대기
</div>
</div>
</div>
{/* 매뉴 승인 */}
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6 text-foreground">
<div className="flex items-center">
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<svg
@@ -273,13 +273,13 @@ function DashboardPage() {
<div className="text-5xl font-bold">
{(3).toLocaleString()}건
</div>
<div className="mt-2 text-2xl text-gray-500">
<div className="mt-2 text-2xl opacity-50">
매뉴 수정 승인 대기
</div>
</div>
</div>
{/* 컨텐츠 승인 */}
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6 text-foreground">
<div className="flex items-center">
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100">
<svg
@@ -307,7 +307,7 @@ function DashboardPage() {
: "0"}
건
</div>
<div className="mt-2 text-2xl text-gray-500">
<div className="mt-2 text-2xl opacity-50">
컨텐츠 수정 승인 대기
</div>
</div>
@@ -318,11 +318,11 @@ function DashboardPage() {
<div className="grid grid-cols-2 gap-1">
{/* 실시간 알림 */}
<div className="card card-border">
<div className="card-header">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">실시간 알림</h3>
</div>
<div className="p-4">
<div className="space-y-4">
<div className="space-y-4 text-foreground">
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">신규 사용자 가입</span>
<span>{(1).toLocaleString()}분 전</span>
@@ -344,11 +344,11 @@ function DashboardPage() {
</div>
{/* 일정 알림 */}
<div className="card card-border">
<div className="card-header">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">일정 알림</h3>
</div>
<div className="p-4">
<div className="space-y-4">
<div className="space-y-4 text-foreground">
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">
{(3).toLocaleString()}월 정기 시스템 점검
@@ -377,7 +377,7 @@ function DashboardPage() {
<div className="grid grid-cols-2 gap-1">
{/* 접속자 통계 */}
<div className="card card-border">
<div className="card-header">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">접속자 통계</h3>
</div>
<div className="h-80 p-4">
@@ -392,7 +392,7 @@ function DashboardPage() {
</div>
{/* 메뉴별 접속 통계 */}
<div className="card card-border">
<div className="card-header">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">메뉴별 접속 통계</h3>
</div>
<div className="h-80 p-4">
@@ -412,14 +412,14 @@ function DashboardPage() {
<div className="grid grid-cols-2 gap-1">
{/* 공지사항 */}
<div className="card card-border !p-0">
<div className="card-header !m-0 !border-none">
<div className="card-header !m-0 !border-none text-foreground">
<h3 className="text-2xl font-medium">공지사항</h3>
</div>
{noticePostTableComponent}
</div>
{/* 신규 게시글 */}
<div className="card card-border !p-0">
<div className="card-header !m-0 !border-none">
<div className="card-header !m-0 !border-none text-foreground">
<h3 className="text-2xl font-medium">신규 게시글</h3>
</div>
{recentPostTableComponent}
@@ -0,0 +1,86 @@
import { ENDPOINT } from "../config";
import { getApiClient } from "./client";
interface PageableParams {
page?: number;
size?: number;
sort?: string[];
}
interface SortConfig {
empty: boolean;
sorted: boolean;
unsorted: boolean;
}
interface PageableConfig {
sort: SortConfig;
offset: number;
pageNumber: number;
pageSize: number;
paged: boolean;
unpaged: boolean;
}
interface PageableList<T> {
content: T[];
pageable: PageableConfig;
sort: SortConfig;
totalPages: number;
totalElements: number;
number: number;
numberOfElements: number;
size: number;
first: boolean;
last: boolean;
empty: boolean;
}
interface NoticeUserListParams extends PageableParams {
recvUnqId?: string;
senderUnqId?: string;
notiType?: string;
notiTtlKeyword?: string;
notiCnKeyword?: string;
notReadYn?: string;
delYn?: string;
}
interface NoticeUser {
notiId: number;
recvUnqId: string;
senderUnqId: string;
notiType: string;
notiTtl: string;
notReadYn: string;
regDt: string;
}
const client = getApiClient();
const getNoticeUserList = async (
recvUnqId: string,
params?: NoticeUserListParams,
) => {
const response = await client.get<PageableList<NoticeUser>>(
`${ENDPOINT.CMS_SERVICE.NOTICE_USER}/${recvUnqId}/list`,
{ params },
);
return response.data;
};
const getUnreadNoticeCount = async (recvUnqId: string) => {
const response = await client.get<PageableList<NoticeUser>>(
`${ENDPOINT.CMS_SERVICE.NOTICE_USER}/${recvUnqId}/unread/count`,
);
return response.data;
};
const readNotice = async (notiId: number) => {
const response = await client.post(
`${ENDPOINT.CMS_SERVICE.NOTICE}/${notiId}/read`,
);
return response.data;
};
export { getNoticeUserList, getUnreadNoticeCount, readNotice };
@@ -7,6 +7,8 @@ export const ENDPOINT = {
POPUPS: "/cms-service/api/v1/popups",
IPS: "/cms-service/api/v1/site-ips",
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
NOTICE: "/cms-service/api/v1/noti",
NOTICE_USER: "/cms-service/api/v1/noti/user",
},
USER_SERVICE: {
USERS: "/user-service/api/v1/users",
@@ -24,7 +24,7 @@ export function Footer() {
const plugin = useRef(Autoplay({ delay: 2000, stopOnInteraction: true }));
return (
<footer className="flex w-full shrink-0 items-start border-t border-solid border-border bg-background px-6 py-8">
<footer className="flex w-full shrink-0 items-start border-t border-solid border-border bg-background px-6 py-8 text-foreground">
<div className="w-1/2">
<h6 className="mb-4 text-2xl font-bold">{siteInfo?.siteNm ?? ""}</h6>
<p className="my-1 text-xl opacity-60">{siteInfo?.siteAddr ?? ""}</p>
@@ -32,10 +32,6 @@ export function Footer() {
전화: {siteInfo?.telNo ?? ""} / 팩스: {siteInfo?.faxNumber ?? ""}
</p>
<p className="my-1 text-xl opacity-60">{siteInfo?.lwndCn ?? ""}</p>
<p className="my-1 text-xl opacity-60">
본 시스템은 Chrome, Firefox, Safari, Edge 브라우저에 최적화되어
있습니다.
</p>
</div>
<div className="flex w-1/2 flex-col">
<div className="flex justify-end gap-4">
@@ -13,16 +13,12 @@ import {
getUserInfo,
type Popup,
} from "../../api";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "../dropdown-menu";
import { Button } from "../button";
import { SidebarTrigger } from "../sidebar";
import { Skeleton } from "../skeleton";
import { SidebarTrigger } from "../sidebar";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
import { SearchForm } from "./search-form";
import { NoticePopover } from "./notice-popover";
const appConfig = getConfig();
const siteId = appConfig.SITE_ID ?? "";
@@ -140,30 +136,28 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
<SearchForm />
{mounted ? (
isLoggedIn && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="group m-0 flex h-[3rem] items-center !bg-background px-4 py-0 text-label"
data-trigger="dropdown"
>
<Popover>
<PopoverTrigger asChild>
<Button className="group m-0 flex h-[3rem] items-center !bg-background px-4 py-0 text-label">
<i className="diveicon di-account-circle static translate-y-0.5 pr-2 text-3xl" />
{userInfo?.userName}
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>프로필 설정</DropdownMenuItem>
<div className="flex flex-col items-start gap-0 px-4 py-3">
<p className="text-base text-label">최종접속일</p>
<p>{userInfo?.lastLoginDate}</p>
</div>
<div className="flex flex-col items-start gap-0 px-4 py-3">
<p className="text-base text-label">접속 IP</p>
<p></p>
</div>
<DropdownMenuItem onClick={logout}>로그아웃</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PopoverTrigger>
<PopoverContent align="end" className="w-48 border-border p-1">
<ul>
<li className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent">
<NoticePopover />
</li>
<li
onClick={logout}
className="cursor-pointer rounded-sm p-3 text-xl hover:bg-accent"
>
로그아웃
</li>
</ul>
</PopoverContent>
</Popover>
)
) : (
<Skeleton className="mx-4 h-[3rem] w-[15rem]" />
@@ -0,0 +1,141 @@
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getConfig } from "@common/config";
import { ENDPOINT } from "../../config";
import { cn, getCookie } from "../../lib";
import {
getNoticeUserList,
getUnreadNoticeCount,
readNotice,
} from "../../api/notice-user";
import { Label } from "../label";
import { Badge } from "../badge";
import { Button } from "../button";
import { Checkbox } from "../checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import "dayjs/locale/ko";
dayjs.extend(relativeTime);
dayjs.locale("ko");
const appConfig = getConfig();
const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
export function NoticePopover() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [selectedNoticeIds, setSelectedNoticeIds] = useState<number[]>([]);
const { data: noticeUserList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE_USER],
queryFn: () => getNoticeUserList(userId!),
enabled: !!userId,
});
const { data: unreadNoticeCount } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE],
queryFn: () => getUnreadNoticeCount(userId!),
enabled: !!userId,
});
const handleSelectNotice = (notiId: number) => {
setSelectedNoticeIds((prevIds) => {
if (prevIds.includes(notiId)) {
return prevIds.filter((id) => id !== notiId);
} else {
return [...prevIds, notiId];
}
});
};
const handleSelectAll = () => {
if (noticeUserList?.content) {
if (selectedNoticeIds.length === noticeUserList.content.length) {
setSelectedNoticeIds([]);
} else {
const allIds = noticeUserList.content.map((item) => item.notiId);
setSelectedNoticeIds(allIds);
}
}
};
const handleReadNotice = async () => {
if (selectedNoticeIds.length === 0) return;
try {
await Promise.all(selectedNoticeIds.map((notiId) => readNotice(notiId)));
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE_USER],
});
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.NOTICE],
});
setSelectedNoticeIds([]);
} catch (error) {
console.error("알림 읽음 처리 중 오류 발생:", error);
}
};
const isAllSelected = noticeUserList?.content
? selectedNoticeIds.length === noticeUserList.content.length &&
noticeUserList.content.length > 0
: false;
useEffect(() => {
if (open) setSelectedNoticeIds([]);
}, [open]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="flex w-full items-center gap-2 text-left">
알림 <Badge>{unreadNoticeCount?.totalElements ?? 0}</Badge>
</div>
</PopoverTrigger>
<PopoverContent className="w-[32rem] border-border px-0">
<div className="flex items-center gap-4 p-4 pt-0">
<Checkbox
id="all-select"
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
<Label htmlFor="all-select" className="text-[1.4rem]">
전체 선택
</Label>
</div>
<ul>
{noticeUserList?.content?.map((item) => (
<li
key={item.notiId}
className={cn(
"flex items-center gap-4 border-t border-border p-4",
item.notReadYn === "N" && "bg-primary-transparent",
)}
>
<Checkbox
checked={selectedNoticeIds.includes(item.notiId)}
onCheckedChange={() => handleSelectNotice(item.notiId)}
/>
<div>
<span className="text-[1.2rem]">{item.notiType}</span>
<p className="text-[1.4rem]">{item.notiTtl}</p>
<span className="text-[1.2rem] opacity-50">
{dayjs(item.regDt).fromNow()}
</span>
</div>
</li>
))}
</ul>
<div
className="border-t border-border pt-4 text-center"
onClick={() => setOpen(false)}
>
<Button onClick={handleReadNotice}>확인</Button>
</div>
</PopoverContent>
</Popover>
);
}