feat(WEB): UniversalListPage 전체 마이그레이션 및 코드 정리

- UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선
- 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션
- 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등)
- 미들웨어 토큰 갱신 로직 개선
- AuthenticatedLayout 구조 개선
- claudedocs 문서 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-16 15:19:09 +09:00
parent 8639eee5df
commit ad493bcea6
90 changed files with 19864 additions and 20305 deletions

View File

@@ -1,18 +1,15 @@
'use client';
/**
* 이벤트 목록 컴포넌트
* 이벤트 목록 - UniversalListPage 마이그레이션
*
* 디자인 스펙:
* - 페이지 타이틀: 이벤트
* - 페이지 설명: 이벤트를 확인합니다.
* - 날짜 범위 선택 + 기간 프리셋 버튼
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
* - 탭: 진행중인 이벤트 / 종료된 이벤트
* - 검색 + 정렬 (최신순)
* - 테이블 컬럼: No, 제목, 작성자, 기간, 조회수
* - 날짜 범위 선택 + 기간 프리셋 버튼
* - 정렬 filterConfig (PC: 인라인, 모바일: 바텀시트)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Calendar } from 'lucide-react';
import { format } from 'date-fns';
@@ -20,158 +17,31 @@ import { TableCell, TableRow } from '@/components/ui/table';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
IntegratedListTemplateV2,
type TableColumn,
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type TabOption,
type PaginationConfig,
} from '@/components/templates/IntegratedListTemplateV2';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
} from '@/components/templates/UniversalListPage';
import {
type Event,
type EventTab,
type SortOption,
SORT_OPTIONS,
transformPostToEvent,
} from './types';
import { getPosts } from '../shared/actions';
const ITEMS_PER_PAGE = 10;
import { getPosts, deletePost } from '../shared/actions';
export function EventList() {
const router = useRouter();
// ===== 상태 관리 =====
const [events, setEvents] = useState<Event[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// ===== API 데이터 로드 =====
useEffect(() => {
async function fetchEvents() {
setIsLoading(true);
setError(null);
const result = await getPosts('events', { per_page: 100 });
if (result.success && result.data) {
const transformed = result.data.data.map(transformPostToEvent);
setEvents(transformed);
} else {
setError(result.error || '이벤트를 불러오는데 실패했습니다.');
}
setIsLoading(false);
}
fetchEvents();
}, []);
const [searchValue, setSearchValue] = useState('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [sortOption, setSortOption] = useState<SortOption>('latest');
const [activeTab, setActiveTab] = useState<EventTab>('ongoing');
// 날짜 범위 (초기값 없음 - 전체 조회)
// ===== 날짜 범위 상태 (외부 관리) =====
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// ===== 오늘 날짜 기준 진행중/종료 판별 =====
const today = format(new Date(), 'yyyy-MM-dd');
// ===== 탭별 이벤트 개수 계산 =====
const eventCounts = useMemo(() => {
const ongoing = events.filter((item) => item.endDate >= today).length;
const ended = events.filter((item) => item.endDate < today).length;
return { ongoing, ended };
}, [events, today]);
// ===== 탭 옵션 (테이블 내부 TabChip 스타일) =====
const tabs: TabOption[] = [
{ value: 'ongoing', label: '진행중인 이벤트', count: eventCounts.ongoing, color: 'blue' },
{ value: 'ended', label: '종료된 이벤트', count: eventCounts.ended, color: 'gray' },
];
// ===== 필터링 및 정렬된 데이터 =====
const filteredData = useMemo(() => {
let result = [...events];
// 탭 필터 (진행중/종료)
if (activeTab === 'ongoing') {
result = result.filter((item) => item.endDate >= today);
} else {
result = result.filter((item) => item.endDate < today);
}
// 날짜 필터 (이벤트 시작일/종료일 범위로 필터)
if (startDate && endDate) {
result = result.filter((item) => {
// 이벤트 기간이 선택한 기간과 겹치는지 확인
return item.startDate <= endDate && item.endDate >= startDate;
});
}
// 검색 필터
if (searchValue) {
const searchLower = searchValue.toLowerCase();
result = result.filter(
(item) =>
item.title.toLowerCase().includes(searchLower) ||
item.author.toLowerCase().includes(searchLower)
);
}
// 정렬
switch (sortOption) {
case 'latest':
result.sort((a, b) => b.startDate.localeCompare(a.startDate));
break;
case 'oldest':
result.sort((a, b) => a.startDate.localeCompare(b.startDate));
break;
case 'views':
result.sort((a, b) => b.viewCount - a.viewCount);
break;
}
return result;
}, [events, searchValue, startDate, endDate, sortOption, activeTab, today]);
// ===== 페이지네이션 =====
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
return filteredData.slice(startIndex, startIndex + ITEMS_PER_PAGE);
}, [filteredData, currentPage]);
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE);
// ===== 핸들러 =====
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
}
}, [selectedItems.size, paginatedData]);
// ===== 행 클릭 핸들러 =====
const handleRowClick = useCallback(
(item: Event) => {
router.push(`/ko/customer-center/events/${item.id}`);
@@ -179,149 +49,211 @@ export function EventList() {
[router]
);
const handleTabChange = useCallback((value: string) => {
setActiveTab(value as EventTab);
setCurrentPage(1); // 탭 변경 시 첫 페이지로
setSelectedItems(new Set()); // 선택 초기화
}, []);
// ===== 테이블 컬럼 =====
const tableColumns: TableColumn[] = [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'title', label: '제목', className: 'min-w-[200px]' },
{ key: 'author', label: '작성자', className: 'w-[100px] text-center' },
{ key: 'period', label: '기간', className: 'w-[200px] text-center' },
{ key: 'viewCount', label: '조회수', className: 'w-[80px] text-center' },
];
// ===== 테이블 행 렌더링 =====
const renderTableRow = useCallback(
(item: Event, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
return (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(item.id)}
/>
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>{item.title}</TableCell>
<TableCell className="text-center">{item.author}</TableCell>
<TableCell className="text-center">
{item.startDate} ~ {item.endDate}
</TableCell>
<TableCell className="text-center">{item.viewCount}</TableCell>
</TableRow>
);
// ===== 탭 카운트 계산 함수 =====
const computeTabs = useCallback(
(data: Event[]): TabOption[] => {
const ongoing = data.filter((item) => item.endDate >= today).length;
const ended = data.filter((item) => item.endDate < today).length;
return [
{ value: 'ongoing', label: '진행중인 이벤트', count: ongoing, color: 'blue' },
{ value: 'ended', label: '종료된 이벤트', count: ended, color: 'gray' },
];
},
[selectedItems, handleRowClick, handleToggleSelection]
[today]
);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback(
(
item: Event,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
return (
<Card
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(item)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">#{globalIndex}</span>
<span className="text-sm text-muted-foreground"> {item.viewCount}</span>
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Event> = useMemo(
() => ({
// 페이지 기본 정보
title: '이벤트',
description: '이벤트를 확인합니다.',
icon: Calendar,
basePath: '/customer-center/events',
// ID 추출
idField: 'id',
// API 액션
actions: {
getList: async () => {
const result = await getPosts('events', { per_page: 100 });
if (result.success && result.data) {
const transformed = result.data.data.map(transformPostToEvent);
return { success: true, data: transformed, totalCount: transformed.length };
}
return { success: false, error: result.error };
},
deleteItem: async (id: string) => {
const result = await deletePost('events', id);
return { success: result.success, error: result.error };
},
},
// 테이블 컬럼
columns: [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'title', label: '제목', className: 'min-w-[200px]' },
{ key: 'author', label: '작성자', className: 'w-[100px] text-center' },
{ key: 'period', label: '기간', className: 'w-[200px] text-center' },
{ key: 'viewCount', label: '조회수', className: 'w-[80px] text-center' },
],
// 클라이언트 사이드 필터링
clientSideFiltering: true,
itemsPerPage: 10,
// 탭 설정 (동적 카운트)
tabs: [
{ value: 'ongoing', label: '진행중인 이벤트', count: 0, color: 'blue' },
{ value: 'ended', label: '종료된 이벤트', count: 0, color: 'gray' },
],
defaultTab: 'ongoing',
// 탭 필터
tabFilter: (item, activeTab) => {
if (activeTab === 'ongoing') {
return item.endDate >= today;
}
return item.endDate < today;
},
// 검색 필터
searchPlaceholder: '제목, 작성자로 검색...',
searchFilter: (item, searchValue) => {
const searchLower = searchValue.toLowerCase();
return (
item.title.toLowerCase().includes(searchLower) ||
item.author.toLowerCase().includes(searchLower)
);
},
// 커스텀 필터 (날짜)
customFilterFn: (items, filterValues) => {
if (!startDate || !endDate) return items;
return items.filter((item) => {
// 이벤트 기간이 선택한 기간과 겹치는지 확인
return item.startDate <= endDate && item.endDate >= startDate;
});
},
// 커스텀 정렬 (filterValues에서 정렬 옵션 참조)
customSortFn: (items, filterValues) => {
const sorted = [...items];
const sortOption = (filterValues.sort as SortOption) || 'latest';
switch (sortOption) {
case 'latest':
sorted.sort((a, b) => b.startDate.localeCompare(a.startDate));
break;
case 'oldest':
sorted.sort((a, b) => a.startDate.localeCompare(b.startDate));
break;
case 'views':
sorted.sort((a, b) => b.viewCount - a.viewCount);
break;
}
return sorted;
},
// 필터 설정 (PC: 인라인, 모바일: 바텀시트)
filterConfig: [
{
key: 'sort',
label: '정렬',
type: 'single',
options: SORT_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label })),
},
],
initialFilters: { sort: 'latest' },
// 공통 헤더 옵션: 날짜 선택기 (왼쪽)
dateRangeSelector: {
enabled: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// 테이블 헤더 액션 (총 건수만 표시)
tableHeaderActions: ({ totalCount }) => (
<span className="text-sm text-muted-foreground"> {totalCount}</span>
),
// 테이블 행 렌더링
renderTableRow: (
item: Event,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Event>
) => {
return (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={handlers.isSelected}
onCheckedChange={handlers.onToggle}
/>
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>{item.title}</TableCell>
<TableCell className="text-center">{item.author}</TableCell>
<TableCell className="text-center">
{item.startDate} ~ {item.endDate}
</TableCell>
<TableCell className="text-center">{item.viewCount}</TableCell>
</TableRow>
);
},
// 모바일 카드 렌더링
renderMobileCard: (
item: Event,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Event>
) => {
return (
<Card
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(item)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={handlers.isSelected}
onCheckedChange={handlers.onToggle}
/>
</div>
<h3 className="font-medium">{item.title}</h3>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<span>{item.author}</span>
<span>|</span>
<span>{item.startDate} ~ {item.endDate}</span>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">#{globalIndex}</span>
<span className="text-sm text-muted-foreground"> {item.viewCount}</span>
</div>
<h3 className="font-medium">{item.title}</h3>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<span>{item.author}</span>
<span>|</span>
<span>
{item.startDate} ~ {item.endDate}
</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
},
[handleRowClick]
</CardContent>
</Card>
);
},
}),
[startDate, endDate, handleRowClick, today]
);
// ===== 페이지네이션 설정 =====
const pagination: PaginationConfig = {
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage: ITEMS_PER_PAGE,
onPageChange: setCurrentPage,
};
return (
<IntegratedListTemplateV2
title="이벤트"
description="이벤트를 확인합니다."
icon={Calendar}
headerActions={
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
}
tabs={tabs}
activeTab={activeTab}
onTabChange={handleTabChange}
searchValue={searchValue}
onSearchChange={setSearchValue}
searchPlaceholder="제목, 작성자로 검색..."
tableHeaderActions={
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{filteredData.length}
</span>
<Select value={sortOption} onValueChange={(v) => setSortOption(v as SortOption)}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
tableColumns={tableColumns}
data={paginatedData}
allData={filteredData}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={pagination}
/>
);
return <UniversalListPage config={config} />;
}
export default EventList;
export default EventList;