Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 34 additions & 20 deletions guard_app/src/api/attendance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,52 @@
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;

Check warning on line 10 in guard_app/src/api/attendance.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type

Check warning on line 10 in guard_app/src/api/attendance.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type

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<AttendanceResponse>(`/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<AttendanceResponse>(`/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 ?? [];
}
15 changes: 11 additions & 4 deletions guard_app/src/navigation/AppTabs.tsx
Original file line number Diff line number Diff line change
@@ -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';

Check warning on line 7 in guard_app/src/navigation/AppTabs.tsx

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

`../screen/AvailabilityScreen` import should occur before import of `../screen/HomeScreen`

Check warning on line 7 in guard_app/src/navigation/AppTabs.tsx

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

`../screen/AvailabilityScreen` import should occur before import of `../screen/HomeScreen`
import DocumentsScreen from '../screen/DocumentsScreen';

Check warning on line 8 in guard_app/src/navigation/AppTabs.tsx

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

`../screen/DocumentsScreen` import should occur before import of `../screen/HomeScreen`

Check warning on line 8 in guard_app/src/navigation/AppTabs.tsx

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

`../screen/DocumentsScreen` import should occur before import of `../screen/HomeScreen`
import HomeScreen from '../screen/HomeScreen';
import TimesheetsScreen from '../screen/TimeSheetsScreen';
import ProfileScreen from '../screen/ProfileScreen';

Check warning on line 10 in guard_app/src/navigation/AppTabs.tsx

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

`../screen/ProfileScreen` import should occur before import of `../screen/ShiftsScreen`

Check warning on line 10 in guard_app/src/navigation/AppTabs.tsx

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

`../screen/ProfileScreen` import should occur before import of `../screen/ShiftsScreen`
import ShiftsScreen from '../screen/ShiftsScreen';

export type AppTabParamList = {
Home: undefined;
Shifts: undefined;
Availability: undefined;
Documents: undefined;
Timesheets: undefined;
Profile: undefined;
};

const Tab = createBottomTabNavigator<AppTabParamList>();

export default function AppTabs() {
return (
<Tab.Navigator
Expand All @@ -30,7 +32,11 @@
? ('briefcase-outline' as const)
: route.name === 'Availability'
? ('calendar-outline' as const)
: ('person-outline' as const);
: route.name === 'Documents'
? ('folder-outline' as const)
: route.name === 'Timesheets'
? ('clipboard-outline' as const)
: ('person-outline' as const);

return <Ionicons name={name} size={size} color={color} />;
},
Expand All @@ -42,6 +48,7 @@
<Tab.Screen name="Shifts" component={ShiftsScreen} />
<Tab.Screen name="Availability" component={AvailabilityScreen} />
<Tab.Screen name="Documents" component={DocumentsScreen} />
<Tab.Screen name="Timesheets" component={TimesheetsScreen} />
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
);
Expand Down
174 changes: 174 additions & 0 deletions guard_app/src/screen/TimeSheetsScreen.tsx
Original file line number Diff line number Diff line change
@@ -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<Attendance[]>([]);

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 (
<View style={s.card}>
<Text style={s.title}>{fmtShiftLabel(item)}</Text>

<View style={s.row}>
<Text style={s.label}>Check In:</Text>
<Text style={s.value}>{fmtDateTime(item.checkInTime ?? null)}</Text>
</View>

<View style={s.row}>
<Text style={s.label}>Check Out:</Text>
<Text style={s.value}>{fmtDateTime(item.checkOutTime ?? null)}</Text>
</View>

<View style={s.rowBetween}>
<Text style={s.meta}>
Status:{' '}
<Text style={checkedIn ? s.ok : s.muted}>
{checkedOut ? 'Completed' : checkedIn ? 'In progress' : 'Not started'}
</Text>
</Text>

<View style={[s.badge, item.locationVerified ? s.badgeOk : s.badgeWarn]}>
<Text style={[s.badgeText, item.locationVerified ? s.badgeTextOk : s.badgeTextWarn]}>
{item.locationVerified ? 'Verified' : 'Not verified'}
</Text>
</View>
</View>
</View>
);
};

if (loading) {
return (
<View style={s.center}>
<ActivityIndicator size="large" color={COLORS.primary} />
<Text style={s.loadingText}>Loading timesheets...</Text>
</View>
);
}

return (
<View style={s.container}>
<FlatList
data={items}
keyExtractor={(item) => item._id}
renderItem={renderItem}
contentContainerStyle={{ padding: 16 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
ListEmptyComponent={<Text style={s.empty}>No timesheet records yet.</Text>}
/>
</View>
);
}

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' },
});
Loading