diff --git a/guard_app/src/api/attendance.ts b/guard_app/src/api/attendance.ts index bf476c7cd..ed1038498 100644 --- a/guard_app/src/api/attendance.ts +++ b/guard_app/src/api/attendance.ts @@ -4,38 +4,52 @@ import http from '../lib/http'; export type Attendance = { _id: string; guardId: string; - shiftId: string; - checkInTime?: string; - checkOutTime?: string; + + // NOTE: + // backend might return shiftId as a string OR a populated object + shiftId: string | any; + + checkInTime?: string | null; + checkOutTime?: string | null; locationVerified: boolean; + + createdAt?: string; + updatedAt?: string; }; -type LocationPayload = { +export type LocationPayload = { latitude: number; longitude: number; timestamp?: number; }; +type AttendanceResponse = { + message: string; + attendance: Attendance; +}; + +// ✅ Check In export async function checkIn(shiftId: string, loc: LocationPayload) { - const { data } = await http.post<{ message: string; attendance: Attendance }>( - `/attendance/checkin/${shiftId}`, - { - latitude: loc.latitude, - longitude: loc.longitude, - timestamp: loc.timestamp, - }, - ); + const { data } = await http.post(`/attendance/checkin/${shiftId}`, { + latitude: loc.latitude, + longitude: loc.longitude, + timestamp: loc.timestamp, + }); return data; } +// ✅ Check Out export async function checkOut(shiftId: string, loc: LocationPayload) { - const { data } = await http.post<{ message: string; attendance: Attendance }>( - `/attendance/checkout/${shiftId}`, - { - latitude: loc.latitude, - longitude: loc.longitude, - timestamp: loc.timestamp, - }, - ); + const { data } = await http.post(`/attendance/checkout/${shiftId}`, { + latitude: loc.latitude, + longitude: loc.longitude, + timestamp: loc.timestamp, + }); return data; } + +// ✅ Timesheets list (requires backend endpoint GET /attendance/my) +export async function getMyAttendance(params?: { from?: string; to?: string }) { + const { data } = await http.get<{ items: Attendance[] }>(`/attendance/my`, { params }); + return data?.items ?? []; +} diff --git a/guard_app/src/navigation/AppTabs.tsx b/guard_app/src/navigation/AppTabs.tsx index 1d8208a63..cd4e2cdbf 100644 --- a/guard_app/src/navigation/AppTabs.tsx +++ b/guard_app/src/navigation/AppTabs.tsx @@ -1,22 +1,24 @@ +// src/navigation/AppTabs.tsx import { Ionicons } from '@expo/vector-icons'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import HomeScreen from '../screen/HomeScreen'; +import ShiftsScreen from '../screen/ShiftsScreen'; import AvailabilityScreen from '../screen/AvailabilityScreen'; import DocumentsScreen from '../screen/DocumentsScreen'; -import HomeScreen from '../screen/HomeScreen'; +import TimesheetsScreen from '../screen/TimeSheetsScreen'; import ProfileScreen from '../screen/ProfileScreen'; -import ShiftsScreen from '../screen/ShiftsScreen'; export type AppTabParamList = { Home: undefined; Shifts: undefined; Availability: undefined; Documents: undefined; + Timesheets: undefined; Profile: undefined; }; const Tab = createBottomTabNavigator(); - export default function AppTabs() { return ( ; }, @@ -42,6 +48,7 @@ export default function AppTabs() { + ); diff --git a/guard_app/src/screen/TimeSheetsScreen.tsx b/guard_app/src/screen/TimeSheetsScreen.tsx new file mode 100644 index 000000000..90d8e18bb --- /dev/null +++ b/guard_app/src/screen/TimeSheetsScreen.tsx @@ -0,0 +1,174 @@ +// src/screen/TimesheetsScreen.tsx +import React, { useCallback, useState } from 'react'; +import { + View, + Text, + StyleSheet, + FlatList, + ActivityIndicator, + RefreshControl, + TouchableOpacity, + Alert, +} from 'react-native'; +import { useFocusEffect } from '@react-navigation/native'; + +import { getMyAttendance, type Attendance } from '../api/attendance'; +import { COLORS } from '../theme/colors'; + +function safeDate(d?: string | null) { + if (!d) return null; + const dt = new Date(d); + return Number.isNaN(dt.getTime()) ? null : dt; +} + +function fmtDateTime(d?: string | null) { + const dt = safeDate(d); + if (!dt) return '—'; + return dt.toLocaleString(); +} + +function fmtShiftLabel(att: Attendance) { + // If backend populates shiftId, it may contain title/date/startTime + const s = att.shiftId as any; + + if (s && typeof s === 'object') { + const title = s.title ?? 'Shift'; + const date = s.date ? new Date(s.date).toDateString() : ''; + const time = s.startTime && s.endTime ? `${s.startTime} - ${s.endTime}` : ''; + const parts = [title, date, time].filter(Boolean); + return parts.join(' • '); + } + + // otherwise shiftId is string + return `Shift ID: ${String(att.shiftId)}`; +} + +export default function TimesheetsScreen() { + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [items, setItems] = useState([]); + + const load = async () => { + try { + const rows = await getMyAttendance(); + setItems(rows); + } catch (e: any) { + const msg = e?.response?.data?.message ?? e?.message ?? 'Failed to load timesheets'; + Alert.alert('Error', msg); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useFocusEffect( + useCallback(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []), + ); + + const onRefresh = async () => { + setRefreshing(true); + await load(); + }; + + const renderItem = ({ item }: { item: Attendance }) => { + const checkedIn = !!item.checkInTime; + const checkedOut = !!item.checkOutTime; + + return ( + + {fmtShiftLabel(item)} + + + Check In: + {fmtDateTime(item.checkInTime ?? null)} + + + + Check Out: + {fmtDateTime(item.checkOutTime ?? null)} + + + + + Status:{' '} + + {checkedOut ? 'Completed' : checkedIn ? 'In progress' : 'Not started'} + + + + + + {item.locationVerified ? 'Verified' : 'Not verified'} + + + + + ); + }; + + if (loading) { + return ( + + + Loading timesheets... + + ); + } + + return ( + + item._id} + renderItem={renderItem} + contentContainerStyle={{ padding: 16 }} + refreshControl={} + ListEmptyComponent={No timesheet records yet.} + /> + + ); +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: COLORS.bg }, + + center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + loadingText: { marginTop: 10, color: COLORS.muted }, + + empty: { textAlign: 'center', marginTop: 30, color: COLORS.muted }, + + card: { + backgroundColor: '#fff', + borderRadius: 14, + padding: 16, + marginBottom: 12, + elevation: 2, + }, + title: { fontSize: 16, fontWeight: '800', color: COLORS.text, marginBottom: 10 }, + + row: { flexDirection: 'row', marginBottom: 6 }, + label: { width: 85, fontWeight: '700', color: COLORS.text }, + value: { flex: 1, color: COLORS.muted }, + + rowBetween: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + + meta: { marginTop: 8, color: COLORS.text, fontWeight: '700' }, + ok: { color: COLORS.status.confirmed, fontWeight: '800' }, + muted: { color: COLORS.muted, fontWeight: '800' }, + + badge: { + marginTop: 8, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 999, + }, + badgeOk: { backgroundColor: '#D1FAE5' }, + badgeWarn: { backgroundColor: '#FEF3C7' }, + + badgeText: { fontWeight: '800', fontSize: 12 }, + badgeTextOk: { color: '#065F46' }, + badgeTextWarn: { color: '#92400E' }, +});