refactor(frontend): 관리자 일정 API도 새 형식에 맞게 업데이트

- admin/schedules.js에 transformSchedule 함수 추가
- AdminSchedule.jsx의 검색 로직을 schedulesApi.searchSchedules 사용으로 변경
- 직접 fetch 호출 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-21 23:35:17 +09:00
parent 55096c8e43
commit 3922d5c6f7
2 changed files with 200 additions and 114 deletions

View file

@ -3,32 +3,55 @@
*/
import { fetchAdminApi, fetchAdminFormData } from "../index";
/**
* API 응답을 프론트엔드 형식으로 변환
* - datetime date, time 분리
* - category 객체 category_id, category_name, category_color 플랫화
* - members 배열 member_names 문자열
*/
function transformSchedule(schedule) {
const category = schedule.category || {};
// datetime에서 date와 time 분리
let date = '';
let time = null;
if (schedule.datetime) {
const parts = schedule.datetime.split('T');
date = parts[0];
time = parts[1] || null;
}
// members 배열을 문자열로 (기존 코드 호환성)
const memberNames = Array.isArray(schedule.members)
? schedule.members.join(',')
: '';
return {
...schedule,
date,
time,
category_id: category.id,
category_name: category.name,
category_color: category.color,
member_names: memberNames,
};
}
// 일정 목록 조회 (월별)
export async function getSchedules(year, month) {
const data = await fetchAdminApi(`/api/schedules?year=${year}&month=${month}`);
// 날짜별 그룹화된 응답을 플랫 배열로 변환
const schedules = [];
for (const [date, dayData] of Object.entries(data)) {
for (const schedule of dayData.schedules) {
const category = schedule.category || {};
schedules.push({
...schedule,
date,
category_id: category.id,
category_name: category.name,
category_color: category.color,
});
}
}
return schedules;
return (data.schedules || []).map(transformSchedule);
}
// 일정 검색 (Meilisearch)
export async function searchSchedules(query) {
return fetchAdminApi(
`/api/admin/schedules/search?q=${encodeURIComponent(query)}`
export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) {
const data = await fetchAdminApi(
`/api/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`
);
return {
...data,
schedules: (data.schedules || []).map(transformSchedule),
};
}
// 일정 상세 조회

View file

@ -45,6 +45,64 @@ const getMemberList = (schedule) => {
return [];
};
// ( )
const getScheduleDate = (schedule) => {
// datetime ( )
if (schedule.datetime) {
return new Date(schedule.datetime);
}
// date ( )
if (schedule.date) {
return new Date(schedule.date);
}
return new Date();
};
// ( )
const getScheduleTime = (schedule) => {
// time ( )
if (schedule.time) {
return schedule.time.slice(0, 5);
}
// datetime ( )
if (schedule.datetime && schedule.datetime.includes('T')) {
const timePart = schedule.datetime.split('T')[1];
if (timePart) {
return timePart.slice(0, 5);
}
}
return null;
};
// ID ( )
const getCategoryId = (schedule) => {
// category_id ( )
if (schedule.category_id !== undefined) {
return schedule.category_id;
}
// category.id ( )
if (schedule.category?.id !== undefined) {
return schedule.category.id;
}
return null;
};
// ( )
const getCategoryInfo = (schedule, categories) => {
const catId = getCategoryId(schedule);
// category
if (schedule.category?.name && schedule.category?.color) {
return {
id: schedule.category.id,
name: schedule.category.name,
color: schedule.category.color
};
}
// categories
const found = categories.find(c => c.id === catId);
return found || { id: catId, name: '미분류', color: '#6b7280' };
};
// ID
const CATEGORY_IDS = {
YOUTUBE: 2,
@ -73,11 +131,13 @@ const ScheduleItem = memo(function ScheduleItem({
navigate,
openDeleteDialog
}) {
const scheduleDate = new Date(schedule.date);
const scheduleDate = getScheduleDate(schedule);
const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
const categoryColor = getColorStyle(categories.find(c => c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280';
const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류';
const categoryInfo = getCategoryInfo(schedule, categories);
const categoryColor = getColorStyle(categoryInfo.color)?.style?.backgroundColor || categoryInfo.color || '#6b7280';
const memberList = getMemberList(schedule);
const timeStr = getScheduleTime(schedule);
const catId = getCategoryId(schedule);
return (
<motion.div
@ -105,15 +165,15 @@ const ScheduleItem = memo(function ScheduleItem({
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900">{decodeHtmlEntities(schedule.title)}</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
{schedule.time && (
{timeStr && (
<span className="flex items-center gap-1">
<Clock size={14} />
{schedule.time.slice(0, 5)}
{timeStr}
</span>
)}
<span className="flex items-center gap-1">
<Tag size={14} />
{categoryName}
{categoryInfo.name}
</span>
{schedule.source?.name && (
<span className="flex items-center gap-1">
@ -220,11 +280,7 @@ function AdminSchedule() {
} = useInfiniteQuery({
queryKey: ['adminScheduleSearch', searchTerm],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(
`/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}`
);
if (!response.ok) throw new Error('Search failed');
return response.json();
return schedulesApi.searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT });
},
getNextPageParam: (lastPage) => {
if (lastPage.hasMore) {
@ -582,7 +638,7 @@ function AdminSchedule() {
if (selectedCategories.length === 0) {
result = [...searchResults];
} else {
result = searchResults.filter(s => selectedCategories.includes(s.category_id));
result = searchResults.filter(s => selectedCategories.includes(getCategoryId(s)));
}
} else {
// :
@ -622,11 +678,12 @@ function AdminSchedule() {
//
//
if (!(isSearchMode && searchTerm) && selectedDate) {
const scheduleDate = formatDate(s.date);
const sDate = getScheduleDate(s);
const scheduleDate = formatDate(sDate);
if (scheduleDate !== selectedDate) return;
}
const catId = s.category_id;
const catId = getCategoryId(s);
counts.set(catId, (counts.get(catId) || 0) + 1);
total++;
});
@ -1274,88 +1331,94 @@ function AdminSchedule() {
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className="p-5 hover:bg-gray-50 transition-colors group border-b border-gray-100">
<div className="flex items-start gap-4">
<div className="w-20 text-center flex-shrink-0">
<div className="text-xs text-gray-400 mb-0.5">
{new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1}
</div>
<div className="text-2xl font-bold text-gray-900">
{new Date(schedule.date).getDate()}
</div>
<div className="text-sm text-gray-500">
{['일', '월', '화', '수', '목', '금', '토'][new Date(schedule.date).getDay()]}요일
</div>
</div>
{(() => {
const scheduleDate = getScheduleDate(schedule);
const categoryInfo = getCategoryInfo(schedule, categories);
const categoryColor = getColorStyle(categoryInfo.color)?.style?.backgroundColor || categoryInfo.color || '#6b7280';
const timeStr = getScheduleTime(schedule);
const catId = getCategoryId(schedule);
const memberList = getMemberList(schedule);
<div
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
style={{ backgroundColor: getColorStyle(categories.find(c => c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }}
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900">{decodeHtmlEntities(schedule.title)}</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
{schedule.time && (
<span className="flex items-center gap-1">
<Clock size={14} />
{schedule.time.slice(0, 5)}
</span>
)}
<span className="flex items-center gap-1">
<Tag size={14} />
{categories.find(c => c.id === schedule.category_id)?.name || '미분류'}
</span>
{schedule.source?.name && (
<span className="flex items-center gap-1">
<Link2 size={14} />
{schedule.source?.name}
</span>
)}
</div>
{(() => {
const memberList = getMemberList(schedule);
if (memberList.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5 mt-2">
{memberList.map((name, i) => (
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
{name}
</span>
))}
return (
<div className="p-5 hover:bg-gray-50 transition-colors group border-b border-gray-100">
<div className="flex items-start gap-4">
<div className="w-20 text-center flex-shrink-0">
<div className="text-xs text-gray-400 mb-0.5">
{scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1}
</div>
);
})()}
</div>
<div className="text-2xl font-bold text-gray-900">
{scheduleDate.getDate()}
</div>
<div className="text-sm text-gray-500">
{['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}요일
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{schedule.source?.url && (
<a
href={schedule.source?.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
>
<ExternalLink size={18} />
</a>
)}
<button
onClick={() => navigate(getEditPath(schedule.id, schedule.category_id))}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => openDeleteDialog(schedule)}
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
>
<Trash2 size={18} />
</button>
<div
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
style={{ backgroundColor: categoryColor }}
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900">{decodeHtmlEntities(schedule.title)}</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
{timeStr && (
<span className="flex items-center gap-1">
<Clock size={14} />
{timeStr}
</span>
)}
<span className="flex items-center gap-1">
<Tag size={14} />
{categoryInfo.name}
</span>
{schedule.source?.name && (
<span className="flex items-center gap-1">
<Link2 size={14} />
{schedule.source?.name}
</span>
)}
</div>
{memberList.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{memberList.map((name, i) => (
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
{name}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{schedule.source?.url && (
<a
href={schedule.source?.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
>
<ExternalLink size={18} />
</a>
)}
<button
onClick={() => navigate(getEditPath(schedule.id, catId))}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
>
<Edit2 size={18} />
</button>
<button
onClick={() => openDeleteDialog(schedule)}
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
})()}</div>
);
})}
</div>