feat: 메인 페이지 일정 API 및 디자인 개선

- 백엔드에 startDate 파라미터 지원 추가 (다가오는 일정 조회)
- handleUpcomingSchedules 함수 구현
- 메인 페이지 일정 카드 디자인을 일정 페이지와 동일하게 변경
- 멤버 표시 로직 API 응답에 맞게 수정 (PC/모바일)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-20 17:27:31 +09:00
parent 8e15cd6d2c
commit f780e91f14
3 changed files with 99 additions and 51 deletions

View file

@ -42,22 +42,28 @@ export default async function schedulesRoutes(fastify) {
search: { type: 'string', description: '검색어' }, search: { type: 'string', description: '검색어' },
year: { type: 'integer', description: '년도' }, year: { type: 'integer', description: '년도' },
month: { type: 'integer', minimum: 1, maximum: 12, description: '월' }, month: { type: 'integer', minimum: 1, maximum: 12, description: '월' },
startDate: { type: 'string', description: '시작 날짜 (YYYY-MM-DD)' },
offset: { type: 'integer', default: 0, description: '페이지 오프셋' }, offset: { type: 'integer', default: 0, description: '페이지 오프셋' },
limit: { type: 'integer', default: 100, description: '결과 개수' }, limit: { type: 'integer', default: 100, description: '결과 개수' },
}, },
}, },
}, },
}, async (request, reply) => { }, async (request, reply) => {
const { search, year, month, offset = 0, limit = 100 } = request.query; const { search, year, month, startDate, offset = 0, limit = 100 } = request.query;
// 검색 모드 // 검색 모드
if (search && search.trim()) { if (search && search.trim()) {
return await handleSearch(fastify, search.trim(), parseInt(offset), parseInt(limit)); return await handleSearch(fastify, search.trim(), parseInt(offset), parseInt(limit));
} }
// 다가오는 일정 조회 (startDate부터)
if (startDate) {
return await handleUpcomingSchedules(db, startDate, parseInt(limit));
}
// 월별 조회 모드 // 월별 조회 모드
if (!year || !month) { if (!year || !month) {
return reply.code(400).send({ error: 'search 또는 year/month는 필수입니다.' }); return reply.code(400).send({ error: 'search, startDate, 또는 year/month는 필수입니다.' });
} }
return await handleMonthlySchedules(db, parseInt(year), parseInt(month)); return await handleMonthlySchedules(db, parseInt(year), parseInt(month));
@ -423,3 +429,65 @@ async function handleMonthlySchedules(db, year, month) {
return grouped; return grouped;
} }
/**
* 다가오는 일정 조회 (startDate부터 limit개)
*/
async function handleUpcomingSchedules(db, startDate, limit) {
// 일정 조회
const [schedules] = await db.query(`
SELECT
s.id,
s.title,
s.date,
s.time,
s.category_id,
c.name as category_name,
c.color as category_color
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
WHERE s.date >= ?
ORDER BY s.date ASC, s.time ASC
LIMIT ?
`, [startDate, limit]);
// 멤버 정보 조회
const scheduleIds = schedules.map(s => s.id);
let memberMap = {};
if (scheduleIds.length > 0) {
const [scheduleMembers] = await db.query(`
SELECT sm.schedule_id, m.name
FROM schedule_members sm
JOIN members m ON sm.member_id = m.id
WHERE sm.schedule_id IN (?)
ORDER BY m.id
`, [scheduleIds]);
for (const sm of scheduleMembers) {
if (!memberMap[sm.schedule_id]) {
memberMap[sm.schedule_id] = [];
}
memberMap[sm.schedule_id].push({ name: sm.name });
}
}
// 결과 포맷팅
return schedules.map(s => {
const scheduleMembers = memberMap[s.id] || [];
const members = scheduleMembers.length >= 5
? [{ name: '프로미스나인' }]
: scheduleMembers;
return {
id: s.id,
title: s.title,
date: s.date,
time: s.time,
category_id: s.category_id,
category_name: s.category_name,
category_color: s.category_color,
members,
};
});
}

View file

@ -176,8 +176,10 @@ function MobileHome() {
const isCurrentYear = scheduleYear === currentYear; const isCurrentYear = scheduleYear === currentYear;
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth; const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
// (5 ) //
const memberList = schedule.member_names ? schedule.member_names.split(',').map(n => n.trim()).filter(Boolean) : []; const memberList = schedule.member_names
? schedule.member_names.split(',').map(n => n.trim()).filter(Boolean)
: schedule.members?.map(m => m.name) || [];
return ( return (
<motion.div <motion.div
@ -237,7 +239,7 @@ function MobileHome() {
{/* 멤버 */} {/* 멤버 */}
{memberList.length > 0 && ( {memberList.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{(memberList.length >= 5 ? ['프로미스나인'] : memberList).map((name, i) => ( {memberList.map((name, i) => (
<span <span
key={i} key={i}
className="px-2 py-0.5 bg-primary/10 text-primary text-[10px] rounded-full font-medium" className="px-2 py-0.5 bg-primary/10 text-primary text-[10px] rounded-full font-medium"

View file

@ -254,9 +254,10 @@ function Home() {
// //
const memberList = schedule.member_names const memberList = schedule.member_names
? schedule.member_names.split(",") ? schedule.member_names.split(",")
: []; : schedule.members?.map(m => m.name) || [];
const displayMembers = const displayMembers = memberList;
memberList.length >= 5 ? ["프로미스나인"] : memberList;
const categoryColor = schedule.category_color || '#6366f1';
return ( return (
<motion.div <motion.div
@ -269,71 +270,48 @@ function Home() {
transition: { duration: 0.4, ease: "easeOut" }, transition: { duration: 0.4, ease: "easeOut" },
}, },
}} }}
className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden" className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer"
>
{/* 날짜 영역 - 카테고리 색상 */}
<div
className="w-24 flex flex-col items-center justify-center text-white py-6"
style={{ backgroundColor: categoryColor }}
> >
{/* 날짜 영역 - primary 색상 고정 */}
<div className="w-20 flex flex-col items-center justify-center text-white py-5 bg-primary">
{/* 현재 년도가 아니면 년.월 표시 */}
{!isCurrentYear && ( {!isCurrentYear && (
<span className="text-xs font-medium opacity-70"> <span className="text-xs font-medium opacity-70">
{scheduleYear}.{scheduleMonth + 1} {scheduleYear}.{scheduleMonth + 1}
</span> </span>
)} )}
{/* 현재 달이 아니면 월 표시 (현재 년도일 때) */}
{isCurrentYear && !isCurrentMonth && ( {isCurrentYear && !isCurrentMonth && (
<span className="text-xs font-medium opacity-70"> <span className="text-xs font-medium opacity-70">
{scheduleMonth + 1} {scheduleMonth + 1}
</span> </span>
)} )}
<span className="text-3xl font-bold">{day}</span> <span className="text-3xl font-bold">{day}</span>
<span className="text-sm font-medium opacity-80"> <span className="text-sm font-medium opacity-80">{weekday}</span>
{weekday}
</span>
</div> </div>
{/* 내용 영역 */} {/* 내용 영역 */}
<div className="flex-1 p-5 flex flex-col justify-center"> <div className="flex-1 p-6 flex flex-col justify-center">
<h3 className="font-bold text-lg text-gray-900 mb-2"> <h3 className="font-bold text-lg mb-2">{schedule.title}</h3>
{schedule.title} <div className="flex flex-wrap gap-3 text-base text-gray-500">
</h3>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500">
{schedule.time && ( {schedule.time && (
<div className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock <Clock size={16} className="opacity-60" />
size={14} {schedule.time.slice(0, 5)}
className="text-primary opacity-60" </span>
/>
<span>{schedule.time.slice(0, 5)}</span>
</div>
)} )}
{schedule.category_name && ( <span className="flex items-center gap-1">
<div className="flex items-center gap-1"> <Tag size={16} className="opacity-60" />
<Tag {schedule.category_name}
size={14} </span>
className="text-primary opacity-60"
/>
<span>{schedule.category_name}</span>
</div> </div>
)}
{schedule.source?.name && (
<div className="flex items-center gap-1">
<Link2
size={14}
className="text-primary opacity-60"
/>
<span>{schedule.source?.name}</span>
</div>
)}
</div>
{/* 멤버 태그 */}
{displayMembers.length > 0 && ( {displayMembers.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3"> <div className="flex flex-wrap gap-1.5 mt-2">
{displayMembers.map((name, i) => ( {displayMembers.map((name, i) => (
<span <span
key={i} key={i}
className="px-2.5 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full" className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full"
> >
{name.trim()} {name.trim()}
</span> </span>