feat(WEB): UniversalListPage 컴포넌트 및 파일럿 마이그레이션

- UniversalListPage 템플릿 컴포넌트 생성
- 카드관리(HR) 파일럿 마이그레이션 (기본 케이스)
- 게시판목록 파일럿 마이그레이션 (동적 탭 fetchTabs)
- 발주관리 파일럿 마이그레이션 (ScheduleCalendar beforeTableContent)
- 클라이언트 사이드 필터링 지원 (customFilterFn, customSortFn)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-14 15:27:59 +09:00
parent b08366c3f7
commit e76fac0ab1
12 changed files with 2450 additions and 91 deletions

View File

@@ -0,0 +1,371 @@
'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/ListMobileCard';
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}/create`);
} else {
router.push('/ko/board/create');
}
}}
>
<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}`);
};
const handleEdit = () => {
router.push(`/ko/board/${item.boardCode}/${item.id}/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}`);
};
const handleEdit = () => {
router.push(`/ko/board/${item.boardCode}/${item.id}/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;