304 lines
7.8 KiB
TypeScript
304 lines
7.8 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
|
|
import { LinearGradient } from "expo-linear-gradient";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { theme } from "../styles/theme";
|
|
|
|
interface AttendanceRecord {
|
|
id: string;
|
|
checkInTime: string;
|
|
checkOutTime?: string | null;
|
|
duration?: number | null;
|
|
}
|
|
|
|
interface AttendanceCalendarProps {
|
|
attendanceRecords: AttendanceRecord[];
|
|
}
|
|
|
|
export function AttendanceCalendar({
|
|
attendanceRecords,
|
|
}: AttendanceCalendarProps) {
|
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
const [calendarDays, setCalendarDays] = useState<
|
|
Array<{ date: Date | null; hasAttendance: boolean; isToday: boolean }>
|
|
>([]);
|
|
|
|
useEffect(() => {
|
|
generateCalendar(currentMonth);
|
|
}, [currentMonth, attendanceRecords]);
|
|
|
|
const generateCalendar = (month: Date) => {
|
|
const year = month.getFullYear();
|
|
const monthIndex = month.getMonth();
|
|
|
|
// Get first day of month and number of days
|
|
const firstDay = new Date(year, monthIndex, 1);
|
|
const lastDay = new Date(year, monthIndex + 1, 0);
|
|
const daysInMonth = lastDay.getDate();
|
|
const startingDayOfWeek = firstDay.getDay();
|
|
|
|
// Create attendance lookup set
|
|
const attendanceDates = new Set(
|
|
attendanceRecords.map((record) =>
|
|
new Date(record.checkInTime).toDateString(),
|
|
),
|
|
);
|
|
|
|
const today = new Date().toDateString();
|
|
|
|
// Build calendar array
|
|
const days: Array<{
|
|
date: Date | null;
|
|
hasAttendance: boolean;
|
|
isToday: boolean;
|
|
}> = [];
|
|
|
|
// Add empty cells for days before month starts
|
|
for (let i = 0; i < startingDayOfWeek; i++) {
|
|
days.push({ date: null, hasAttendance: false, isToday: false });
|
|
}
|
|
|
|
// Add days of the month
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const date = new Date(year, monthIndex, day);
|
|
const dateString = date.toDateString();
|
|
days.push({
|
|
date,
|
|
hasAttendance: attendanceDates.has(dateString),
|
|
isToday: dateString === today,
|
|
});
|
|
}
|
|
|
|
setCalendarDays(days);
|
|
};
|
|
|
|
const goToPreviousMonth = () => {
|
|
setCurrentMonth(
|
|
new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1),
|
|
);
|
|
};
|
|
|
|
const goToNextMonth = () => {
|
|
setCurrentMonth(
|
|
new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1),
|
|
);
|
|
};
|
|
|
|
const monthName = currentMonth.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<LinearGradient
|
|
colors={[theme.colors.white, theme.colors.gray50]}
|
|
style={[styles.card, theme.shadows.medium]}
|
|
>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<Text style={styles.title}>Attendance Calendar</Text>
|
|
</View>
|
|
|
|
{/* Month Navigation */}
|
|
<View style={styles.monthNav}>
|
|
<TouchableOpacity
|
|
onPress={goToPreviousMonth}
|
|
style={styles.navButton}
|
|
>
|
|
<Ionicons
|
|
name="chevron-back"
|
|
size={20}
|
|
color={theme.colors.primary}
|
|
/>
|
|
</TouchableOpacity>
|
|
<Text style={styles.monthText}>{monthName}</Text>
|
|
<TouchableOpacity onPress={goToNextMonth} style={styles.navButton}>
|
|
<Ionicons
|
|
name="chevron-forward"
|
|
size={20}
|
|
color={theme.colors.primary}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Day Headers */}
|
|
<View style={styles.dayHeaders}>
|
|
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => (
|
|
<Text key={day} style={styles.dayHeader}>
|
|
{day}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
|
|
{/* Calendar Grid */}
|
|
<View style={styles.calendarGrid}>
|
|
{calendarDays.map((day, index) => (
|
|
<View key={index} style={styles.dayCell}>
|
|
{day.date ? (
|
|
<View
|
|
style={[
|
|
styles.dayContent,
|
|
day.isToday && styles.todayContent,
|
|
day.hasAttendance && styles.attendanceContent,
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.dayText,
|
|
day.isToday && styles.todayText,
|
|
day.hasAttendance && styles.attendanceText,
|
|
]}
|
|
>
|
|
{day.date.getDate()}
|
|
</Text>
|
|
{day.hasAttendance && <View style={styles.attendanceDot} />}
|
|
</View>
|
|
) : (
|
|
<View style={styles.emptyCell} />
|
|
)}
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
{/* Legend */}
|
|
<View style={styles.legend}>
|
|
<View style={styles.legendItem}>
|
|
<View style={styles.legendDotAttendance} />
|
|
<Text style={styles.legendText}>Attended</Text>
|
|
</View>
|
|
<View style={styles.legendItem}>
|
|
<View style={styles.legendDotToday} />
|
|
<Text style={styles.legendText}>Today</Text>
|
|
</View>
|
|
</View>
|
|
</LinearGradient>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
paddingHorizontal: 20,
|
|
marginBottom: 20,
|
|
},
|
|
card: {
|
|
borderRadius: theme.borderRadius["2xl"],
|
|
padding: 20,
|
|
},
|
|
header: {
|
|
marginBottom: 16,
|
|
},
|
|
title: {
|
|
fontSize: theme.typography.fontSize.xl,
|
|
fontWeight: theme.typography.fontWeight.bold,
|
|
color: theme.colors.gray800,
|
|
},
|
|
monthNav: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginBottom: 16,
|
|
paddingHorizontal: 8,
|
|
},
|
|
navButton: {
|
|
padding: 8,
|
|
},
|
|
monthText: {
|
|
fontSize: theme.typography.fontSize.lg,
|
|
fontWeight: theme.typography.fontWeight.semibold,
|
|
color: theme.colors.gray700,
|
|
},
|
|
dayHeaders: {
|
|
flexDirection: "row",
|
|
marginBottom: 8,
|
|
},
|
|
dayHeader: {
|
|
flex: 1,
|
|
textAlign: "center",
|
|
fontSize: theme.typography.fontSize.xs,
|
|
fontWeight: theme.typography.fontWeight.semibold,
|
|
color: theme.colors.gray500,
|
|
},
|
|
calendarGrid: {
|
|
flexDirection: "row",
|
|
flexWrap: "wrap",
|
|
},
|
|
dayCell: {
|
|
width: `${100 / 7}%`,
|
|
aspectRatio: 1,
|
|
padding: 2,
|
|
},
|
|
dayContent: {
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
borderRadius: theme.borderRadius.md,
|
|
position: "relative",
|
|
},
|
|
todayContent: {
|
|
backgroundColor: theme.colors.primaryLight,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.primary,
|
|
},
|
|
attendanceContent: {
|
|
backgroundColor: theme.colors.successLight,
|
|
},
|
|
emptyCell: {
|
|
flex: 1,
|
|
},
|
|
dayText: {
|
|
fontSize: theme.typography.fontSize.sm,
|
|
color: theme.colors.gray700,
|
|
fontWeight: theme.typography.fontWeight.medium,
|
|
},
|
|
todayText: {
|
|
color: theme.colors.white,
|
|
fontWeight: theme.typography.fontWeight.bold,
|
|
},
|
|
attendanceText: {
|
|
color: theme.colors.white,
|
|
fontWeight: theme.typography.fontWeight.bold,
|
|
},
|
|
attendanceDot: {
|
|
position: "absolute",
|
|
bottom: 2,
|
|
width: 4,
|
|
height: 4,
|
|
borderRadius: 2,
|
|
backgroundColor: theme.colors.white,
|
|
},
|
|
legend: {
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
gap: 20,
|
|
marginTop: 16,
|
|
paddingTop: 16,
|
|
borderTopWidth: 1,
|
|
borderTopColor: theme.colors.gray200,
|
|
},
|
|
legendItem: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
},
|
|
legendDotAttendance: {
|
|
width: 12,
|
|
height: 12,
|
|
borderRadius: 6,
|
|
backgroundColor: theme.colors.successLight,
|
|
},
|
|
legendDotToday: {
|
|
width: 12,
|
|
height: 12,
|
|
borderRadius: 6,
|
|
backgroundColor: theme.colors.primaryLight,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.primary,
|
|
},
|
|
legendText: {
|
|
fontSize: theme.typography.fontSize.xs,
|
|
color: theme.colors.gray600,
|
|
},
|
|
});
|