284 lines
8.2 KiB
TypeScript
284 lines
8.2 KiB
TypeScript
|
|
import React, { useEffect, useState, useCallback } from 'react';
|
||
|
|
import {
|
||
|
|
View,
|
||
|
|
Text,
|
||
|
|
FlatList,
|
||
|
|
TextInput,
|
||
|
|
TouchableOpacity,
|
||
|
|
ActivityIndicator,
|
||
|
|
RefreshControl,
|
||
|
|
} from 'react-native';
|
||
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
|
|
import { Ionicons } from '@expo/vector-icons';
|
||
|
|
|
||
|
|
import { getSchedules, getCategories, Schedule, ScheduleCategory } from '../api/schedules';
|
||
|
|
import { colors } from '../constants/colors';
|
||
|
|
|
||
|
|
export default function ScheduleScreen() {
|
||
|
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||
|
|
const [categories, setCategories] = useState<ScheduleCategory[]>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [refreshing, setRefreshing] = useState(false);
|
||
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
||
|
|
|
||
|
|
const [searchQuery, setSearchQuery] = useState('');
|
||
|
|
const [selectedCategory, setSelectedCategory] = useState<number | null>(null);
|
||
|
|
const [offset, setOffset] = useState(0);
|
||
|
|
const [hasMore, setHasMore] = useState(true);
|
||
|
|
|
||
|
|
const LIMIT = 20;
|
||
|
|
|
||
|
|
const fetchCategories = async () => {
|
||
|
|
try {
|
||
|
|
const data = await getCategories();
|
||
|
|
setCategories(data);
|
||
|
|
} catch (error) {
|
||
|
|
console.error('카테고리 로드 오류:', error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const fetchSchedules = async (isRefresh = false) => {
|
||
|
|
if (loadingMore && !isRefresh) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const currentOffset = isRefresh ? 0 : offset;
|
||
|
|
const { schedules: data, total } = await getSchedules({
|
||
|
|
q: searchQuery || undefined,
|
||
|
|
category: selectedCategory || undefined,
|
||
|
|
limit: LIMIT,
|
||
|
|
offset: currentOffset,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (isRefresh) {
|
||
|
|
setSchedules(data);
|
||
|
|
setOffset(LIMIT);
|
||
|
|
} else {
|
||
|
|
setSchedules(prev => [...prev, ...data]);
|
||
|
|
setOffset(prev => prev + LIMIT);
|
||
|
|
}
|
||
|
|
|
||
|
|
setHasMore(currentOffset + data.length < total);
|
||
|
|
} catch (error) {
|
||
|
|
console.error('일정 로드 오류:', error);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
setRefreshing(false);
|
||
|
|
setLoadingMore(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetchCategories();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
setLoading(true);
|
||
|
|
setOffset(0);
|
||
|
|
setHasMore(true);
|
||
|
|
fetchSchedules(true);
|
||
|
|
}, [searchQuery, selectedCategory]);
|
||
|
|
|
||
|
|
const onRefresh = () => {
|
||
|
|
setRefreshing(true);
|
||
|
|
fetchSchedules(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const onEndReached = () => {
|
||
|
|
if (!loadingMore && hasMore) {
|
||
|
|
setLoadingMore(true);
|
||
|
|
fetchSchedules();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 날짜 포맷
|
||
|
|
const formatDate = (dateStr: string) => {
|
||
|
|
const date = new Date(dateStr);
|
||
|
|
const month = date.getMonth() + 1;
|
||
|
|
const day = date.getDate();
|
||
|
|
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
|
||
|
|
const weekday = weekdays[date.getDay()];
|
||
|
|
return `${month}/${day} (${weekday})`;
|
||
|
|
};
|
||
|
|
|
||
|
|
const renderScheduleItem = ({ item }: { item: Schedule }) => (
|
||
|
|
<View style={{
|
||
|
|
backgroundColor: colors.backgroundSecondary,
|
||
|
|
borderRadius: 12,
|
||
|
|
padding: 14,
|
||
|
|
marginBottom: 10,
|
||
|
|
borderLeftWidth: 4,
|
||
|
|
borderLeftColor: item.category_color || colors.primary,
|
||
|
|
}}>
|
||
|
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||
|
|
<Text style={{
|
||
|
|
fontSize: 12,
|
||
|
|
color: item.category_color || colors.textSecondary,
|
||
|
|
fontWeight: '500',
|
||
|
|
}}>
|
||
|
|
{item.category_name}
|
||
|
|
</Text>
|
||
|
|
<Text style={{ fontSize: 12, color: colors.textTertiary }}>
|
||
|
|
{formatDate(item.date)}
|
||
|
|
</Text>
|
||
|
|
</View>
|
||
|
|
|
||
|
|
<Text
|
||
|
|
style={{
|
||
|
|
fontSize: 15,
|
||
|
|
fontWeight: '600',
|
||
|
|
color: colors.textPrimary,
|
||
|
|
marginTop: 6,
|
||
|
|
}}
|
||
|
|
numberOfLines={2}
|
||
|
|
>
|
||
|
|
{item.title}
|
||
|
|
</Text>
|
||
|
|
|
||
|
|
{item.time && (
|
||
|
|
<Text style={{
|
||
|
|
fontSize: 12,
|
||
|
|
color: colors.textSecondary,
|
||
|
|
marginTop: 4,
|
||
|
|
}}>
|
||
|
|
{item.time.slice(0, 5)}
|
||
|
|
</Text>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{item.members && item.members.length > 0 && (
|
||
|
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginTop: 8, gap: 4 }}>
|
||
|
|
{item.members.map(m => (
|
||
|
|
<View
|
||
|
|
key={m.id}
|
||
|
|
style={{
|
||
|
|
backgroundColor: colors.primary + '20',
|
||
|
|
paddingHorizontal: 8,
|
||
|
|
paddingVertical: 2,
|
||
|
|
borderRadius: 10,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Text style={{ fontSize: 11, color: colors.primary }}>
|
||
|
|
{m.name}
|
||
|
|
</Text>
|
||
|
|
</View>
|
||
|
|
))}
|
||
|
|
</View>
|
||
|
|
)}
|
||
|
|
</View>
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||
|
|
{/* 헤더 */}
|
||
|
|
<View style={{
|
||
|
|
paddingHorizontal: 16,
|
||
|
|
paddingVertical: 12,
|
||
|
|
borderBottomWidth: 1,
|
||
|
|
borderBottomColor: colors.borderLight,
|
||
|
|
}}>
|
||
|
|
<Text style={{
|
||
|
|
fontSize: 20,
|
||
|
|
fontWeight: 'bold',
|
||
|
|
color: colors.textPrimary,
|
||
|
|
textAlign: 'center',
|
||
|
|
}}>
|
||
|
|
일정
|
||
|
|
</Text>
|
||
|
|
</View>
|
||
|
|
|
||
|
|
{/* 검색 */}
|
||
|
|
<View style={{ paddingHorizontal: 16, paddingVertical: 12 }}>
|
||
|
|
<View style={{
|
||
|
|
flexDirection: 'row',
|
||
|
|
alignItems: 'center',
|
||
|
|
backgroundColor: colors.backgroundSecondary,
|
||
|
|
borderRadius: 10,
|
||
|
|
paddingHorizontal: 12,
|
||
|
|
}}>
|
||
|
|
<Ionicons name="search" size={20} color={colors.textTertiary} />
|
||
|
|
<TextInput
|
||
|
|
style={{
|
||
|
|
flex: 1,
|
||
|
|
paddingVertical: 10,
|
||
|
|
paddingHorizontal: 10,
|
||
|
|
fontSize: 15,
|
||
|
|
color: colors.textPrimary,
|
||
|
|
}}
|
||
|
|
placeholder="일정 검색..."
|
||
|
|
placeholderTextColor={colors.textTertiary}
|
||
|
|
value={searchQuery}
|
||
|
|
onChangeText={setSearchQuery}
|
||
|
|
/>
|
||
|
|
{searchQuery && (
|
||
|
|
<TouchableOpacity onPress={() => setSearchQuery('')}>
|
||
|
|
<Ionicons name="close-circle" size={20} color={colors.textTertiary} />
|
||
|
|
</TouchableOpacity>
|
||
|
|
)}
|
||
|
|
</View>
|
||
|
|
</View>
|
||
|
|
|
||
|
|
{/* 카테고리 필터 */}
|
||
|
|
<View style={{ paddingHorizontal: 16, marginBottom: 12 }}>
|
||
|
|
<FlatList
|
||
|
|
horizontal
|
||
|
|
showsHorizontalScrollIndicator={false}
|
||
|
|
data={[{ id: null, name: '전체', color: colors.textSecondary }, ...categories]}
|
||
|
|
keyExtractor={(item) => String(item.id)}
|
||
|
|
renderItem={({ item }) => (
|
||
|
|
<TouchableOpacity
|
||
|
|
onPress={() => setSelectedCategory(item.id)}
|
||
|
|
style={{
|
||
|
|
paddingHorizontal: 14,
|
||
|
|
paddingVertical: 6,
|
||
|
|
borderRadius: 16,
|
||
|
|
marginRight: 8,
|
||
|
|
backgroundColor: selectedCategory === item.id
|
||
|
|
? colors.primary
|
||
|
|
: colors.backgroundSecondary,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Text style={{
|
||
|
|
fontSize: 13,
|
||
|
|
color: selectedCategory === item.id ? 'white' : colors.textSecondary,
|
||
|
|
fontWeight: selectedCategory === item.id ? '600' : '400',
|
||
|
|
}}>
|
||
|
|
{item.name}
|
||
|
|
</Text>
|
||
|
|
</TouchableOpacity>
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
</View>
|
||
|
|
|
||
|
|
{/* 일정 목록 */}
|
||
|
|
{loading ? (
|
||
|
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||
|
|
<ActivityIndicator size="large" color={colors.primary} />
|
||
|
|
</View>
|
||
|
|
) : (
|
||
|
|
<FlatList
|
||
|
|
data={schedules}
|
||
|
|
keyExtractor={(item) => String(item.id)}
|
||
|
|
renderItem={renderScheduleItem}
|
||
|
|
contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 20 }}
|
||
|
|
refreshControl={
|
||
|
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||
|
|
}
|
||
|
|
onEndReached={onEndReached}
|
||
|
|
onEndReachedThreshold={0.5}
|
||
|
|
ListEmptyComponent={
|
||
|
|
<View style={{ padding: 40, alignItems: 'center' }}>
|
||
|
|
<Text style={{ color: colors.textTertiary }}>
|
||
|
|
일정이 없습니다.
|
||
|
|
</Text>
|
||
|
|
</View>
|
||
|
|
}
|
||
|
|
ListFooterComponent={
|
||
|
|
loadingMore ? (
|
||
|
|
<ActivityIndicator style={{ padding: 20 }} color={colors.primary} />
|
||
|
|
) : null
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</SafeAreaView>
|
||
|
|
);
|
||
|
|
}
|