2025-11-18 09:09:09 +09:00
|
|
|
'use client';
|
|
|
|
|
|
2025-12-01 09:56:04 +09:00
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
2025-11-18 09:09:09 +09:00
|
|
|
import { useRouter } from 'next/navigation';
|
|
|
|
|
import PaperClipSvg from '../svgs/paperclipsvg';
|
2025-12-01 09:56:04 +09:00
|
|
|
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
|
|
|
|
import apiService from '../lib/apiService';
|
|
|
|
|
import type { Notice } from '../admin/notices/mockData';
|
2025-11-18 09:09:09 +09:00
|
|
|
|
|
|
|
|
export default function NoticesPage() {
|
|
|
|
|
const router = useRouter();
|
2025-12-01 09:56:04 +09:00
|
|
|
const ITEMS_PER_PAGE = 10;
|
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
|
const [notices, setNotices] = useState<Notice[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
|
|
|
|
|
|
|
|
// 공지사항 리스트 가져오기
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let isMounted = true;
|
|
|
|
|
|
|
|
|
|
async function fetchNotices() {
|
|
|
|
|
try {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
const response = await apiService.getNotices();
|
|
|
|
|
|
|
|
|
|
if (response.status !== 200 || !response.data) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
|
|
|
|
let noticesArray: any[] = [];
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(response.data)) {
|
|
|
|
|
noticesArray = response.data;
|
|
|
|
|
total = response.data.length;
|
|
|
|
|
} else if (response.data && typeof response.data === 'object') {
|
|
|
|
|
noticesArray = response.data.items || response.data.notices || response.data.data || response.data.list || [];
|
|
|
|
|
total = response.data.total !== undefined ? response.data.total :
|
|
|
|
|
response.data.totalCount !== undefined ? response.data.totalCount :
|
|
|
|
|
response.data.count !== undefined ? response.data.count :
|
|
|
|
|
noticesArray.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
|
|
|
|
const formatDate = (dateString: string): string => {
|
|
|
|
|
if (!dateString) return '';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
if (isNaN(date.getTime())) {
|
|
|
|
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
|
|
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
|
|
|
|
return dateString;
|
|
|
|
|
}
|
|
|
|
|
return dateString;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const year = date.getFullYear();
|
|
|
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
|
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
|
|
|
return `${year}-${month}-${day}`;
|
|
|
|
|
} catch {
|
|
|
|
|
return dateString;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// API 응답 데이터를 Notice 형식으로 변환
|
|
|
|
|
const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({
|
|
|
|
|
id: notice.id || notice.noticeId || 0,
|
|
|
|
|
title: notice.title || '',
|
|
|
|
|
date: formatDate(notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0]),
|
|
|
|
|
views: notice.views || notice.viewCount || 0,
|
|
|
|
|
writer: notice.writer || notice.author || notice.createdBy || '관리자',
|
|
|
|
|
content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined,
|
|
|
|
|
hasAttachment: notice.hasAttachment || notice.attachment || false,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
if (!isMounted) return;
|
|
|
|
|
|
|
|
|
|
// 날짜 내림차순 정렬 (최신 날짜가 먼저)
|
|
|
|
|
const sortedNotices = [...transformedNotices].sort((a, b) => b.date.localeCompare(a.date));
|
|
|
|
|
setNotices(sortedNotices);
|
|
|
|
|
setTotalCount(total || sortedNotices.length);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('공지사항 리스트 조회 오류:', error);
|
|
|
|
|
} finally {
|
|
|
|
|
if (isMounted) {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fetchNotices();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
isMounted = false;
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
|
|
|
|
const pagedNotices = useMemo(
|
|
|
|
|
() => notices.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE),
|
|
|
|
|
[notices, currentPage]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 페이지네이션: 10개씩 표시
|
|
|
|
|
const pageGroup = Math.floor((currentPage - 1) / 10);
|
|
|
|
|
const startPage = pageGroup * 10 + 1;
|
|
|
|
|
const endPage = Math.min(startPage + 9, totalPages);
|
|
|
|
|
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
2025-11-18 09:09:09 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="w-full bg-white">
|
|
|
|
|
<div className="flex justify-center">
|
|
|
|
|
<div className="w-full max-w-[1440px]">
|
|
|
|
|
{/* 헤더 영역 */}
|
|
|
|
|
<div className="h-[100px] flex items-center px-8">
|
|
|
|
|
<h1 className="m-0 text-[24px] font-bold leading-[1.5] text-[#1B2027]">
|
|
|
|
|
공지사항
|
|
|
|
|
</h1>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 본문 영역 */}
|
|
|
|
|
<section className="px-8 pb-8">
|
|
|
|
|
{/* 총 건수 */}
|
|
|
|
|
<div className="h-10 flex items-center">
|
|
|
|
|
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
|
2025-12-01 09:56:04 +09:00
|
|
|
총 <span className="text-[#384FBF]">{totalCount}</span>건
|
2025-11-18 09:09:09 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-12-01 09:56:04 +09:00
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex items-center justify-center h-[240px]">
|
|
|
|
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
2025-11-18 09:09:09 +09:00
|
|
|
</div>
|
2025-12-01 09:56:04 +09:00
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{/* 표 */}
|
|
|
|
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
|
|
|
|
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
|
|
|
|
번호
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
|
|
|
|
제목
|
2025-11-18 09:09:09 +09:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
2025-12-01 09:56:04 +09:00
|
|
|
게시일
|
2025-11-18 09:09:09 +09:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
2025-12-01 09:56:04 +09:00
|
|
|
조회수
|
2025-11-18 09:09:09 +09:00
|
|
|
</div>
|
2025-12-01 09:56:04 +09:00
|
|
|
<div className="flex items-center px-4">작성자</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 바디 */}
|
|
|
|
|
<div>
|
|
|
|
|
{pagedNotices.length === 0 ? (
|
|
|
|
|
<div className="flex items-center justify-center h-[240px]">
|
|
|
|
|
<p className="text-[16px] font-medium text-[#6C7682]">공지사항이 없습니다.</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
pagedNotices.map((notice, index) => {
|
|
|
|
|
// 번호는 전체 목록에서의 순서 (최신이 1번)
|
|
|
|
|
const noticeNumber = totalCount - ((currentPage - 1) * ITEMS_PER_PAGE + index);
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={notice.id}
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onClick={() => router.push(`/notices/${notice.id}`)}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
router.push(`/notices/${notice.id}`);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
|
|
|
|
|
{noticeNumber}
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
|
|
|
|
title={notice.title}
|
|
|
|
|
>
|
|
|
|
|
{notice.title}
|
|
|
|
|
{notice.hasAttachment && (
|
|
|
|
|
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
|
|
|
|
{notice.date}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
|
|
|
|
{notice.views.toLocaleString()}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center px-4">{notice.writer}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
|
|
|
|
{totalCount > ITEMS_PER_PAGE && (
|
|
|
|
|
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
|
|
|
|
<div className="flex items-center justify-center gap-[8px]">
|
|
|
|
|
{/* First (맨 앞으로) */}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setCurrentPage(1)}
|
|
|
|
|
aria-label="맨 앞 페이지"
|
|
|
|
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
|
|
|
|
disabled={currentPage === 1}
|
|
|
|
|
>
|
|
|
|
|
<div className="relative flex items-center justify-center w-full h-full">
|
|
|
|
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
|
|
|
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Prev */}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
|
|
|
aria-label="이전 페이지"
|
|
|
|
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
|
|
|
|
disabled={currentPage === 1}
|
|
|
|
|
>
|
|
|
|
|
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Numbers */}
|
|
|
|
|
{visiblePages.map((n) => {
|
|
|
|
|
const active = n === currentPage;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={n}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setCurrentPage(n)}
|
|
|
|
|
aria-current={active ? 'page' : undefined}
|
|
|
|
|
className={[
|
|
|
|
|
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
|
|
|
|
active ? 'bg-bg-primary-light' : 'bg-white',
|
|
|
|
|
].join(' ')}
|
|
|
|
|
>
|
|
|
|
|
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
{/* Next */}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
|
|
|
aria-label="다음 페이지"
|
|
|
|
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
|
|
|
|
disabled={currentPage === totalPages}
|
|
|
|
|
>
|
|
|
|
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Last (맨 뒤로) */}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setCurrentPage(totalPages)}
|
|
|
|
|
aria-label="맨 뒤 페이지"
|
|
|
|
|
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
|
|
|
|
disabled={currentPage === totalPages}
|
|
|
|
|
>
|
|
|
|
|
<div className="relative flex items-center justify-center w-full h-full">
|
|
|
|
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
|
|
|
|
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
2025-11-19 23:36:05 +09:00
|
|
|
</div>
|
2025-12-01 09:56:04 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2025-11-18 09:09:09 +09:00
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|