- 등록(?mode=new), 상세(?mode=view), 수정(?mode=edit) URL 패턴 일괄 적용
- 중복 패턴 제거: /edit?mode=edit → ?mode=edit (16개 파일)
- 제목 일관성: {기능} 등록/상세/수정 패턴 적용
- 검수 체크리스트 문서 추가 (79개 페이지)
- UniversalListPage, IntegratedDetailTemplate 공통 컴포넌트 개선
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 게시판 리스트 - UniversalListPage 버전
|
|
*
|
|
* 특이 케이스:
|
|
* - 동적 탭 (API에서 게시판 목록 로드)
|
|
* - 탭별 다른 API 호출 (일반 게시판 vs 나의 게시글)
|
|
* - 본인 글만 수정/삭제 가능
|
|
*/
|
|
|
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { format } from 'date-fns';
|
|
import { FileText, Plus, Pencil, Trash2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { TableRow, TableCell } from '@/components/ui/table';
|
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
|
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
|
import {
|
|
UniversalListPage,
|
|
type UniversalListConfig,
|
|
type SelectionHandlers,
|
|
type RowClickHandlers,
|
|
type TabOption,
|
|
} from '@/components/templates/UniversalListPage';
|
|
import type { Post } from '../types';
|
|
import { getBoards } from '../BoardManagement/actions';
|
|
import { getPosts, getMyPosts, deletePost } from '../actions';
|
|
import type { Board } from '../BoardManagement/types';
|
|
|
|
export function BoardListUnified() {
|
|
const router = useRouter();
|
|
|
|
// 동적 탭을 위한 게시판 목록 상태
|
|
const [boards, setBoards] = useState<Board[]>([]);
|
|
const [activeTab, setActiveTab] = useState<string>('');
|
|
const [currentUserId, setCurrentUserId] = useState<string>('');
|
|
|
|
// 날짜 범위 필터 상태
|
|
const [startDate, setStartDate] = useState('');
|
|
const [endDate, setEndDate] = useState('');
|
|
|
|
// 현재 사용자 ID 가져오기
|
|
useEffect(() => {
|
|
const userId = localStorage.getItem('user_id') || '';
|
|
setCurrentUserId(userId);
|
|
}, []);
|
|
|
|
// 게시판 목록 로드 및 동적 탭 생성
|
|
const fetchTabs = useCallback(async (): Promise<TabOption[]> => {
|
|
const result = await getBoards();
|
|
if (result.success && result.data) {
|
|
setBoards(result.data);
|
|
// 첫 번째 게시판을 기본 탭으로 설정
|
|
if (result.data.length > 0 && !activeTab) {
|
|
setActiveTab(result.data[0].boardCode);
|
|
}
|
|
|
|
const boardTabs = result.data.map((board) => ({
|
|
value: board.boardCode,
|
|
label: board.boardName,
|
|
count: 0,
|
|
}));
|
|
|
|
return [
|
|
...boardTabs,
|
|
{
|
|
value: 'my',
|
|
label: '나의 게시글',
|
|
count: 0,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
}, [activeTab]);
|
|
|
|
// UniversalListPage Config 정의
|
|
const config: UniversalListConfig<Post> = useMemo(() => ({
|
|
// ===== 페이지 기본 정보 =====
|
|
title: '게시판',
|
|
description: '게시판의 게시글을 등록하고 관리합니다.',
|
|
icon: FileText,
|
|
basePath: '/board',
|
|
|
|
// ===== ID 추출 =====
|
|
idField: 'id',
|
|
|
|
// ===== API 액션 =====
|
|
actions: {
|
|
getList: async (params) => {
|
|
const tab = params?.tab || activeTab;
|
|
if (!tab) {
|
|
return { success: true, data: [], totalCount: 0 };
|
|
}
|
|
|
|
let result;
|
|
if (tab === 'my') {
|
|
// 나의 게시글 조회
|
|
result = await getMyPosts({
|
|
search: params?.search,
|
|
per_page: params?.pageSize || 20,
|
|
page: params?.page || 1,
|
|
});
|
|
} else {
|
|
// 특정 게시판 게시글 조회
|
|
result = await getPosts(tab, {
|
|
search: params?.search,
|
|
per_page: params?.pageSize || 20,
|
|
page: params?.page || 1,
|
|
});
|
|
}
|
|
|
|
if (result.success && result.posts) {
|
|
return {
|
|
success: true,
|
|
data: result.posts,
|
|
totalCount: result.data?.total || result.posts.length,
|
|
totalPages: result.data?.last_page || 1,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: result.error,
|
|
data: [],
|
|
totalCount: 0,
|
|
};
|
|
},
|
|
deleteItem: async (id: string) => {
|
|
// 게시글 삭제는 boardCode가 필요하므로 별도 처리
|
|
// UniversalListPage에서는 사용하지 않고 커스텀 삭제 처리
|
|
return { success: false, error: 'Use custom delete handler' };
|
|
},
|
|
},
|
|
|
|
// ===== 테이블 컬럼 =====
|
|
columns: [
|
|
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
|
{ key: 'title', label: '제목', className: 'min-w-[300px]' },
|
|
{ key: 'author', label: '작성자', className: 'w-[120px]' },
|
|
{ key: 'createdAt', label: '등록일', className: 'w-[120px]' },
|
|
{ key: 'viewCount', label: '조회수', className: 'w-[80px] text-center' },
|
|
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
|
|
],
|
|
|
|
// ===== 동적 탭 =====
|
|
fetchTabs,
|
|
defaultTab: activeTab || undefined,
|
|
|
|
// ===== 검색 설정 =====
|
|
searchPlaceholder: '제목, 작성자 검색...',
|
|
|
|
// ===== 상세 보기 모드 =====
|
|
detailMode: 'none', // 커스텀 라우팅 사용 (boardCode 포함)
|
|
|
|
// ===== 헤더 액션 =====
|
|
headerActions: ({ onCreate }) => (
|
|
<>
|
|
<DateRangeSelector
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onStartDateChange={setStartDate}
|
|
onEndDateChange={setEndDate}
|
|
/>
|
|
<Button
|
|
className="ml-auto"
|
|
onClick={() => {
|
|
const boardCode = activeTab !== 'my' ? activeTab : boards[0]?.boardCode;
|
|
if (boardCode) {
|
|
router.push(`/ko/board/${boardCode}?mode=new`);
|
|
} else {
|
|
router.push('/ko/board?mode=new');
|
|
}
|
|
}}
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
게시글 등록
|
|
</Button>
|
|
</>
|
|
),
|
|
|
|
// ===== 삭제 확인 메시지 =====
|
|
deleteConfirmMessage: {
|
|
title: '게시글 삭제',
|
|
description: '정말 삭제하시겠습니까?',
|
|
},
|
|
|
|
// ===== 테이블 행 렌더링 =====
|
|
renderTableRow: (
|
|
item: Post,
|
|
index: number,
|
|
globalIndex: number,
|
|
handlers: SelectionHandlers & RowClickHandlers<Post>
|
|
) => {
|
|
const { isSelected, onToggle } = handlers;
|
|
const isMyPost = item.authorId === currentUserId;
|
|
|
|
const handleRowClick = () => {
|
|
router.push(`/ko/board/${item.boardCode}/${item.id}?mode=view`);
|
|
};
|
|
|
|
const handleEdit = () => {
|
|
router.push(`/ko/board/${item.boardCode}/${item.id}?mode=edit`);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (confirm('정말 삭제하시겠습니까?')) {
|
|
const result = await deletePost(item.boardCode, item.id);
|
|
if (result.success) {
|
|
window.location.reload(); // 삭제 후 새로고침
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<TableRow
|
|
key={item.id}
|
|
className="hover:bg-muted/50 cursor-pointer"
|
|
onClick={handleRowClick}
|
|
>
|
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
|
</TableCell>
|
|
{/* No. */}
|
|
<TableCell className="text-center text-sm text-gray-500">
|
|
{item.isPinned ? (
|
|
<span className="inline-flex items-center justify-center w-6 h-6 bg-red-500 text-white rounded-full text-xs font-bold">
|
|
공지
|
|
</span>
|
|
) : (
|
|
globalIndex
|
|
)}
|
|
</TableCell>
|
|
{/* 제목 */}
|
|
<TableCell className="font-medium">
|
|
<div className="flex items-center gap-2">
|
|
{item.isPinned && (
|
|
<span className="text-xs text-red-500 font-medium">[공지]</span>
|
|
)}
|
|
{item.isSecret && (
|
|
<span className="text-xs text-gray-500 font-medium">[비밀]</span>
|
|
)}
|
|
<span className="truncate">{item.title}</span>
|
|
</div>
|
|
</TableCell>
|
|
{/* 작성자 */}
|
|
<TableCell>{item.authorName}</TableCell>
|
|
{/* 등록일 */}
|
|
<TableCell>{format(new Date(item.createdAt), 'yyyy-MM-dd')}</TableCell>
|
|
{/* 조회수 */}
|
|
<TableCell className="text-center">{item.viewCount}</TableCell>
|
|
{/* 작업 */}
|
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
{isSelected && isMyPost && (
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
|
|
onClick={handleEdit}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
|
onClick={handleDelete}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
},
|
|
|
|
// ===== 모바일 카드 렌더링 =====
|
|
renderMobileCard: (
|
|
item: Post,
|
|
index: number,
|
|
globalIndex: number,
|
|
handlers: SelectionHandlers & RowClickHandlers<Post>
|
|
) => {
|
|
const { isSelected, onToggle } = handlers;
|
|
const isMyPost = item.authorId === currentUserId;
|
|
|
|
const handleRowClick = () => {
|
|
router.push(`/ko/board/${item.boardCode}/${item.id}?mode=view`);
|
|
};
|
|
|
|
const handleEdit = () => {
|
|
router.push(`/ko/board/${item.boardCode}/${item.id}?mode=edit`);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (confirm('정말 삭제하시겠습니까?')) {
|
|
const result = await deletePost(item.boardCode, item.id);
|
|
if (result.success) {
|
|
window.location.reload();
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ListMobileCard
|
|
key={item.id}
|
|
id={item.id}
|
|
title={
|
|
<div className="flex items-center gap-2">
|
|
{item.isPinned && (
|
|
<span className="text-xs text-red-500 font-medium">[공지]</span>
|
|
)}
|
|
{item.isSecret && (
|
|
<span className="text-xs text-gray-500 font-medium">[비밀]</span>
|
|
)}
|
|
<span>{item.title}</span>
|
|
</div>
|
|
}
|
|
isSelected={isSelected}
|
|
onToggleSelection={onToggle}
|
|
infoGrid={
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<InfoField label="작성자" value={item.authorName} />
|
|
<InfoField label="등록일" value={format(new Date(item.createdAt), 'yyyy-MM-dd')} />
|
|
<InfoField label="조회수" value={String(item.viewCount)} />
|
|
<InfoField label="게시판" value={item.boardName} />
|
|
</div>
|
|
}
|
|
actions={
|
|
isSelected && isMyPost ? (
|
|
<div className="flex gap-2 w-full">
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={handleEdit}
|
|
>
|
|
<Pencil className="w-4 h-4 mr-2" /> 수정
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
|
|
onClick={handleDelete}
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" /> 삭제
|
|
</Button>
|
|
</div>
|
|
) : undefined
|
|
}
|
|
onClick={handleRowClick}
|
|
/>
|
|
);
|
|
},
|
|
|
|
// ===== 추가 옵션 =====
|
|
showCheckbox: true,
|
|
showRowNumber: true,
|
|
itemsPerPage: 20,
|
|
}), [activeTab, boards, currentUserId, fetchTabs, router, startDate, endDate]);
|
|
|
|
return (
|
|
<UniversalListPage<Post>
|
|
config={config}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default BoardListUnified;
|