diff --git a/guard_app/src/api/availability.ts b/guard_app/src/api/availability.ts index 2d52c18f..3c3706f2 100644 --- a/guard_app/src/api/availability.ts +++ b/guard_app/src/api/availability.ts @@ -1,7 +1,7 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - +// src/api/availability.ts import http from '../lib/http'; +// Existing types (keep for backward compatibility) export interface AvailabilityData { userId: string; days: string[]; @@ -15,6 +15,47 @@ interface AvailabilityResponse { }; } +// New types for calendar-based availability +export type AvailabilitySlotDto = { + _id: string; + guardId: string; + date: string; // "2025-12-25" ISO date format + fromTime: string; // "09:00" + toTime: string; // "17:00" + recurring?: { + enabled: boolean; + pattern: 'weekly' | 'daily'; + endDate?: string; + }; + createdAt: string; + updatedAt?: string; +}; + +type AvailabilitySlotListResponse = + | AvailabilitySlotDto[] + | { items?: AvailabilitySlotDto[] } + | { data?: AvailabilitySlotDto[] } + | { availability?: AvailabilitySlotDto[] }; + +type AvailabilitySlotResponse = { + message?: string; + availability?: AvailabilitySlotDto; + data?: AvailabilitySlotDto; +}; + +// Utility to normalize array responses (same pattern as shifts.ts) +function toArray(payload: AvailabilitySlotListResponse | any): T[] { + if (Array.isArray(payload)) return payload; + if (Array.isArray((payload as any)?.items)) return (payload as any).items; + if (Array.isArray((payload as any)?.data)) return (payload as any).data; + if (Array.isArray((payload as any)?.availability)) return (payload as any).availability; + return []; +} + +// ============================================================================ +// EXISTING ENDPOINTS (keep for backward compatibility) +// ============================================================================ + // GET /api/v1/availability/:userId export const getAvailability = async (userId: string): Promise => { try { @@ -45,3 +86,45 @@ export const upsertAvailability = async ( const res = await http.post('/availability', payload); return res.data; }; + +// ============================================================================ +// NEW ENDPOINTS (for calendar-based availability management) +// ============================================================================ + +// POST /api/v1/availability/slots +export async function addAvailabilitySlot(params: { + date: string; + fromTime: string; + toTime: string; + recurring?: { + enabled: boolean; + pattern: 'weekly' | 'daily'; + endDate?: string; + }; +}) { + const { data } = await http.post('/availability/slots', params); + return data.availability || data.data || data; +} + +// GET /api/v1/availability/slots/my-slots +export async function getMyAvailabilitySlots(params?: { + startDate?: string; + endDate?: string; +}) { + const { data } = await http.get('/availability/slots/my-slots', { + params, + }); + return toArray(data); +} + +// DELETE /api/v1/availability/slots/:id +export async function removeAvailabilitySlot(id: string) { + const { data } = await http.delete(`/availability/slots/${id}`); + return data; +} + +// DELETE /api/v1/availability/slots/clear-all +export async function clearAllAvailabilitySlots() { + const { data } = await http.delete('/availability/slots/clear-all'); + return data; +} diff --git a/guard_app/src/navigation/AppNavigator.tsx b/guard_app/src/navigation/AppNavigator.tsx index fabfacd6..778c710c 100644 --- a/guard_app/src/navigation/AppNavigator.tsx +++ b/guard_app/src/navigation/AppNavigator.tsx @@ -10,8 +10,6 @@ import SettingsScreen from '../screen/SettingsScreen'; import ShiftDetailsScreen from '../screen/ShiftDetailsScreen'; import SignupScreen from '../screen/signupscreen'; import SplashScreen from '../screen/SplashScreen'; -import DocumentsScreen from '../screen/DocumentsScreen'; - export type RootStackParamList = { AppTabs: undefined; Splash: undefined; diff --git a/guard_app/src/screen/AvailabilityScreen.tsx b/guard_app/src/screen/AvailabilityScreen.tsx index f0f41ad0..02eb19b1 100644 --- a/guard_app/src/screen/AvailabilityScreen.tsx +++ b/guard_app/src/screen/AvailabilityScreen.tsx @@ -1,22 +1,26 @@ // guard_app/src/screen/AvailabilityScreen.tsx -import React, { useEffect, useState } from 'react'; -import { View, Text, TouchableOpacity, ActivityIndicator, Alert, StyleSheet } from 'react-native'; - +import React, { useEffect, useState, useMemo } from 'react'; +import { + View, + Text, + TouchableOpacity, + ActivityIndicator, + Alert, + StyleSheet, + ScrollView, + Modal, + TextInput, +} from 'react-native'; import { getMe } from '../api/auth'; import { getAvailability, upsertAvailability, type AvailabilityData } from '../api/availability'; import AddAvailabilityModal from '../components/AddAvailabilityModal'; -/* ✅ DEV MOCK TO BYPASS BACKEND */ -const DEV_MOCK_AVAILABILITY = __DEV__ && true; - -// IMPORTANT: days must match WEEK_DAYS values (full names), because the UI checks days.includes(day) -const mockAvailability: { days: string[]; timeSlots: string[] } = { - days: ['Monday', 'Wednesday'], - timeSlots: ['Monday 09:00 - 17:00', 'Wednesday 10:00 - 14:00'], -}; - const WEEK_DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; +const SHORT_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const TIME_SLOTS = Array.from({ length: 17 }, (_, i) => i + 6); // 6am to 10pm + +type ViewMode = 'simple' | 'weekly' | 'monthly'; function extractTimeSlots(data: unknown): string[] { if (!data || typeof data !== 'object') return []; @@ -25,41 +29,117 @@ function extractTimeSlots(data: unknown): string[] { } export default function AvailabilityScreen() { + // Simple mode state (existing) const [userId, setUserId] = useState(null); const [days, setDays] = useState([]); const [timeSlots, setTimeSlots] = useState([]); + // Calendar mode state + const [currentDate, setCurrentDate] = useState(new Date()); + const [selectedDate, setSelectedDate] = useState(null); + const [showCalendarModal, setShowCalendarModal] = useState(false); + const [fromTime, setFromTime] = useState('09:00'); + const [toTime, setToTime] = useState('17:00'); + + // UI state + const [viewMode, setViewMode] = useState('simple'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [modalVisible, setModalVisible] = useState(false); + // Helper functions + const formatDate = (date: Date) => date.toISOString().split('T')[0]; + + const getDayName = (date: Date) => { + return WEEK_DAYS[date.getDay() === 0 ? 6 : date.getDay() - 1]; + }; + + const addDays = (date: Date, days: number) => { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; + }; + + const getWeekStart = (date: Date) => { + const d = new Date(date); + const day = d.getDay(); + const diff = d.getDate() - day + (day === 0 ? -6 : 1); + return new Date(d.setDate(diff)); + }; + + const getMonthStart = (date: Date) => { + return new Date(date.getFullYear(), date.getMonth(), 1); + }; + + const getMonthEnd = (date: Date) => { + return new Date(date.getFullYear(), date.getMonth() + 1, 0); + }; + + const parseTimeRange = (slot: string) => { + const [start, end] = slot.split('-'); + if (!start || !end) return null; + const [startHour, startMin] = start.split(':').map(Number); + const [endHour, endMin] = end.split(':').map(Number); + if ([startHour, startMin, endHour, endMin].some((n) => Number.isNaN(n))) return null; + return { + startMinutes: startHour * 60 + startMin, + endMinutes: endHour * 60 + endMin, + }; + }; + + const persistAvailability = async ( + nextDays: string[], + nextTimeSlots: string[], + options?: { showSuccess?: boolean }, + ) => { + if (!userId) { + Alert.alert('Error', 'User not loaded'); + return false; + } + + if (nextDays.length === 0 || nextTimeSlots.length === 0) { + Alert.alert('Validation', 'Please select at least one day and one time slot.'); + return false; + } + + try { + setSaving(true); + const payload: AvailabilityData = { userId, days: nextDays, timeSlots: nextTimeSlots }; + await upsertAvailability(payload); + if (options?.showSuccess) { + Alert.alert('Success', 'Availability saved'); + } + return true; + } catch (e) { + console.error(e); + Alert.alert('Error', 'Failed to save availability'); + return false; + } finally { + setSaving(false); + } + }; + + // Load simple availability useEffect(() => { const load = async () => { try { setLoading(true); setError(null); - // ✅ MOCK MODE: never call backend - if (DEV_MOCK_AVAILABILITY) { - setUserId('dev-user'); - setDays(mockAvailability.days); - setTimeSlots(mockAvailability.timeSlots); - return; - } - - // ✅ REAL MODE + // Real mode - load from API const me = await getMe(); const id = me?._id ?? me?.id; if (!id) throw new Error('Unable to determine user ID'); - setUserId(id); + // Load simple availability const data = await getAvailability(id); if (data) { setDays(Array.isArray(data.days) ? data.days : []); setTimeSlots(extractTimeSlots(data)); } + } catch (e) { console.error(e); setError('Failed to load availability'); @@ -71,6 +151,7 @@ export default function AvailabilityScreen() { void load(); }, []); + // Simple mode functions (existing) const toggleDay = (day: string) => { setDays((prev) => (prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day])); }; @@ -89,51 +170,103 @@ export default function AvailabilityScreen() { }; const handleSave = async () => { - if (!userId) { - Alert.alert('Error', 'User not loaded'); - return; - } + await persistAvailability(days, timeSlots, { showSuccess: true }); + }; - if (days.length === 0 || timeSlots.length === 0) { - Alert.alert('Validation', 'Please select at least one day and one time slot.'); + // Calendar mode functions (new) + const weekDays = useMemo(() => { + const start = getWeekStart(currentDate); + return Array.from({ length: 7 }, (_, i) => addDays(start, i)); + }, [currentDate]); + + const monthDays = useMemo(() => { + const start = getMonthStart(currentDate); + const end = getMonthEnd(currentDate); + const startDay = start.getDay(); + const gridStart = addDays(start, -(startDay === 0 ? 6 : startDay - 1)); + + return Array.from({ length: 42 }, (_, i) => { + const day = addDays(gridStart, i); + return { date: day, inMonth: day >= start && day <= end }; + }); + }, [currentDate]); + + const getSlotsForDate = (date: Date) => { + const dayName = getDayName(date); + if (!days.includes(dayName)) return []; + return timeSlots; + }; + + const handleAddCalendarSlot = async () => { + if (!selectedDate) return; + + if (!fromTime || !toTime) { + Alert.alert('Error', 'Please enter both start and end times'); return; } - // ✅ MOCK MODE: pretend save succeeded - if (DEV_MOCK_AVAILABILITY) { - Alert.alert('Success', 'Availability saved (mock)'); + if (fromTime >= toTime) { + Alert.alert('Error', 'End time must be after start time'); return; } - try { - setSaving(true); + const dayName = getDayName(selectedDate); + const slotLabel = `${fromTime}-${toTime}`; + const nextDays = days.includes(dayName) ? days : [...days, dayName]; + const nextTimeSlots = timeSlots.includes(slotLabel) ? timeSlots : [...timeSlots, slotLabel]; + setDays(nextDays); + setTimeSlots(nextTimeSlots); - const payload: AvailabilityData = { - userId, - days, - timeSlots, - }; + const saved = await persistAvailability(nextDays, nextTimeSlots, { showSuccess: true }); + if (!saved) return; - await upsertAvailability(payload); - Alert.alert('Success', 'Availability saved'); - } catch (e) { - console.error(e); - Alert.alert('Error', 'Failed to save availability'); - } finally { - setSaving(false); + setShowCalendarModal(false); + setFromTime('09:00'); + setToTime('17:00'); + setSelectedDate(null); + }; + + const handleRemoveCalendarSlot = async (slotLabel: string) => { + Alert.alert('Remove', 'Remove this time slot?', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: async () => { + const nextTimeSlots = timeSlots.filter((s) => s !== slotLabel); + if (nextTimeSlots.length === 0) { + Alert.alert('Validation', 'Please keep at least one time slot.'); + return; + } + + setTimeSlots(nextTimeSlots); + await persistAvailability(days, nextTimeSlots); + }, + }, + ]); + }; + + // Navigation + const goToPrevious = () => { + if (viewMode === 'weekly') { + setCurrentDate(addDays(currentDate, -7)); + } else if (viewMode === 'monthly') { + setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)); } }; - if (loading) { - return ( - - - Loading availability… - - ); - } + const goToNext = () => { + if (viewMode === 'weekly') { + setCurrentDate(addDays(currentDate, 7)); + } else if (viewMode === 'monthly') { + setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)); + } + }; - return ( + const goToToday = () => setCurrentDate(new Date()); + + // Render functions + const renderSimpleMode = () => ( {error && {error}} @@ -199,17 +332,315 @@ export default function AvailabilityScreen() { /> ); + + const renderWeeklyMode = () => ( + + + {weekDays.map((day, idx) => ( + + {SHORT_DAYS[idx]} + {day.getDate()} + + ))} + + + + {TIME_SLOTS.map((hour) => ( + + + {hour.toString().padStart(2, '0')}:00 + + + {weekDays.map((day, idx) => { + const daySlots = getSlotsForDate(day); + const hourStartMinutes = hour * 60; + const hourEndMinutes = hour * 60 + 60; + const hasSlot = daySlots.some((slot) => { + const range = parseTimeRange(slot); + if (!range) return false; + return range.startMinutes < hourEndMinutes && range.endMinutes > hourStartMinutes; + }); + + return ( + { + setSelectedDate(day); + setShowCalendarModal(true); + }} + > + {hasSlot && } + + ); + })} + + ))} + + + + This Week's Availability + {weekDays.map((day, idx) => { + const daySlots = getSlotsForDate(day); + if (daySlots.length === 0) return null; + + return ( + + + {SHORT_DAYS[idx]} {day.getDate()} + + {daySlots.map((slot) => ( + + {slot} + handleRemoveCalendarSlot(slot)}> + Remove + + + ))} + + ); + })} + + + ); + + const renderMonthlyMode = () => ( + + + + {SHORT_DAYS.map((day, idx) => ( + + {day} + + ))} + + + + {monthDays.map((dayObj, idx) => { + const daySlots = getSlotsForDate(dayObj.date); + const hasSlots = daySlots.length > 0; + const isToday = formatDate(dayObj.date) === formatDate(new Date()); + + return ( + { + setSelectedDate(dayObj.date); + setShowCalendarModal(true); + }} + > + + {dayObj.date.getDate()} + + {hasSlots && ( + + {daySlots.slice(0, 3).map((slot) => ( + + ))} + + )} + + ); + })} + + + + ); + + if (loading) { + return ( + + + Loading availability… + + ); + } + + const headerDate = + viewMode === 'weekly' + ? `${weekDays[0].toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${weekDays[6].toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}` + : viewMode === 'monthly' + ? currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) + : ''; + + return ( + + {/* View Mode Toggle */} + + setViewMode('simple')} + > + + Simple + + + setViewMode('weekly')} + > + + Weekly + + + setViewMode('monthly')} + > + + Monthly + + + + + {/* Navigation (for calendar views) */} + {viewMode !== 'simple' && ( + + + + + + + Today + + + {headerDate} + + + + + + )} + + {/* Content based on view mode */} + {viewMode === 'simple' && renderSimpleMode()} + {viewMode === 'weekly' && renderWeeklyMode()} + {viewMode === 'monthly' && renderMonthlyMode()} + + {/* Calendar Add Modal */} + + + + Add Availability + + {selectedDate && ( + + Date + + {selectedDate.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + + )} + + + From + + + + + To + + + + + { + setShowCalendarModal(false); + setSelectedDate(null); + setFromTime('09:00'); + setToTime('17:00'); + }} + > + Cancel + + + + Add + + + + + + + ); } const styles = StyleSheet.create({ + fullContainer: { flex: 1, backgroundColor: '#f5f5f5' }, container: { flex: 1, padding: 16 }, centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, loadingText: { marginTop: 8 }, errorText: { color: 'red', marginBottom: 12 }, + // View Toggle + viewToggle: { + flexDirection: 'row', + backgroundColor: '#E5E7EB', + borderRadius: 8, + padding: 4, + margin: 16, + }, + toggleBtn: { flex: 1, paddingVertical: 8, alignItems: 'center', borderRadius: 6 }, + toggleBtnActive: { backgroundColor: '#003f88' }, + toggleText: { fontSize: 14, fontWeight: '600', color: '#6B7280' }, + toggleTextActive: { color: '#FFFFFF' }, + + // Navigation + navigation: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#FFFFFF', + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + }, + navBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, + navBtnText: { fontSize: 24, fontWeight: '600', color: '#003f88' }, + todayBtn: { + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: '#F3F4F6', + borderRadius: 6, + marginLeft: 8, + }, + todayBtnText: { fontSize: 14, fontWeight: '600', color: '#003f88' }, + navDate: { flex: 1, textAlign: 'center', fontSize: 16, fontWeight: '600', color: '#111827' }, + + // Simple Mode (existing) sectionTitle: { fontWeight: 'bold', fontSize: 16, marginBottom: 8 }, helperTextMuted: { color: '#888', marginBottom: 8 }, - daysRow: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 16 }, dayChip: { paddingHorizontal: 10, @@ -223,7 +654,6 @@ const styles = StyleSheet.create({ dayChipSelected: { backgroundColor: '#e3ecff', borderColor: '#003f88' }, dayChipText: { color: '#000' }, dayChipTextSelected: { color: '#003f88', fontWeight: '600' }, - slotRow: { flexDirection: 'row', justifyContent: 'space-between', @@ -231,7 +661,6 @@ const styles = StyleSheet.create({ marginVertical: 4, }, slotItem: { color: '#111' }, - removeButton: { paddingHorizontal: 10, paddingVertical: 4, @@ -241,10 +670,8 @@ const styles = StyleSheet.create({ backgroundColor: '#fef2f2', }, removeButtonText: { color: '#b91c1c', fontSize: 12, fontWeight: '600' }, - actionsRow: { flexDirection: 'row', marginTop: 12 }, spacer: { width: 8 }, - primaryButton: { flex: 1, backgroundColor: '#003f88', @@ -254,7 +681,6 @@ const styles = StyleSheet.create({ }, primaryButtonDisabled: { opacity: 0.6 }, primaryButtonText: { color: '#fff', fontWeight: '700' }, - secondaryButton: { flex: 1, borderRadius: 10, @@ -265,6 +691,97 @@ const styles = StyleSheet.create({ backgroundColor: '#f5f5f5', }, secondaryButtonText: { color: '#333', fontWeight: '600' }, - saveButtonWrapper: { marginTop: 24 }, + + // Calendar Views + calendarContainer: { flex: 1 }, + weekHeader: { + flexDirection: 'row', + backgroundColor: '#FFFFFF', + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + }, + weekDay: { flex: 1, alignItems: 'center', paddingVertical: 12 }, + weekDayName: { fontSize: 12, fontWeight: '600', color: '#6B7280', marginBottom: 4 }, + weekDayDate: { fontSize: 16, fontWeight: '700', color: '#111827' }, + timeGrid: { flex: 1 }, + timeRow: { flexDirection: 'row', height: 60, borderBottomWidth: 1, borderBottomColor: '#F3F4F6' }, + timeLabel: { width: 60, alignItems: 'center', justifyContent: 'center', backgroundColor: '#F9FAFB' }, + timeLabelText: { fontSize: 12, color: '#6B7280', fontWeight: '500' }, + timeCell: { flex: 1, borderLeftWidth: 1, borderLeftColor: '#F3F4F6', backgroundColor: '#FFFFFF' }, + timeCellFilled: { backgroundColor: '#E0F2FE' }, + slotIndicator: { flex: 1, backgroundColor: '#003f88', opacity: 0.3 }, + slotsSummary: { backgroundColor: '#FFFFFF', padding: 16, borderTopWidth: 1, borderTopColor: '#E5E7EB' }, + slotsSummaryTitle: { fontSize: 16, fontWeight: '700', color: '#111827', marginBottom: 12 }, + daySummary: { marginBottom: 16 }, + daySummaryDay: { fontSize: 14, fontWeight: '600', color: '#6B7280', marginBottom: 8 }, + slotItemRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 12, + backgroundColor: '#F9FAFB', + borderRadius: 6, + marginBottom: 4, + }, + slotTime: { fontSize: 14, color: '#111827', fontWeight: '500' }, + + // Monthly View + monthGrid: { backgroundColor: '#FFFFFF', padding: 8 }, + monthWeekHeader: { flexDirection: 'row', marginBottom: 8 }, + monthWeekDay: { flex: 1, alignItems: 'center' }, + monthWeekDayText: { fontSize: 12, fontWeight: '600', color: '#6B7280' }, + monthDaysGrid: { flexDirection: 'row', flexWrap: 'wrap' }, + monthDay: { + width: '14.28%', + aspectRatio: 1, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: '#E5E7EB', + padding: 4, + }, + monthDayDim: { opacity: 0.3 }, + monthDayToday: { backgroundColor: '#E0F2FE' }, + monthDayNumber: { fontSize: 14, fontWeight: '600', color: '#111827' }, + monthDayNumberDim: { color: '#9CA3AF' }, + monthDayNumberToday: { color: '#003f88' }, + monthSlotIndicators: { flexDirection: 'row', gap: 2, marginTop: 4 }, + monthSlotDot: { width: 4, height: 4, borderRadius: 2, backgroundColor: '#003f88' }, + + // Modal + modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center' }, + modalContent: { backgroundColor: '#FFFFFF', borderRadius: 16, padding: 24, width: '85%', maxWidth: 400 }, + modalTitle: { fontSize: 20, fontWeight: '700', color: '#111827', marginBottom: 20 }, + modalField: { marginBottom: 16 }, + modalLabel: { fontSize: 14, fontWeight: '600', color: '#6B7280', marginBottom: 8 }, + modalValue: { fontSize: 16, color: '#111827', fontWeight: '500' }, + modalInput: { + backgroundColor: '#F9FAFB', + borderWidth: 1, + borderColor: '#E5E7EB', + borderRadius: 8, + padding: 12, + fontSize: 16, + color: '#111827', + }, + modalButtons: { flexDirection: 'row', gap: 12, marginTop: 24 }, + modalBtnCancel: { + flex: 1, + paddingVertical: 12, + alignItems: 'center', + borderRadius: 8, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + modalBtnCancelText: { fontSize: 16, fontWeight: '600', color: '#6B7280' }, + modalBtnAdd: { + flex: 1, + paddingVertical: 12, + alignItems: 'center', + borderRadius: 8, + backgroundColor: '#003f88', + }, + modalBtnAddText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' }, }); diff --git a/guard_app/src/screen/DocumentsScreen.tsx b/guard_app/src/screen/DocumentsScreen.tsx index b624c7b3..0770e78f 100644 --- a/guard_app/src/screen/DocumentsScreen.tsx +++ b/guard_app/src/screen/DocumentsScreen.tsx @@ -1,8 +1,18 @@ +// guard_app/src/screen/DocumentsScreen.tsx + +import React, { useState, useCallback } from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Alert, + ScrollView, + ActivityIndicator, +} from 'react-native'; +import * as DocumentPicker from 'expo-document-picker'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useFocusEffect } from '@react-navigation/native'; -import * as DocumentPicker from 'expo-document-picker'; -import React, { useState, useCallback } from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native'; // Document types available for guards const DOCUMENT_TYPES = [ @@ -24,36 +34,40 @@ interface UploadedDocument { size: number; uri: string; uploadedAt: string; - verified: boolean; } +const STORAGE_KEY = 'uploaded_documents'; + export default function DocumentsScreen() { const [documents, setDocuments] = useState([]); const [selectedDocType, setSelectedDocType] = useState(''); const [showDropdown, setShowDropdown] = useState(false); const [uploading, setUploading] = useState(false); - // Load documents from storage + // Load documents from local storage const loadDocuments = useCallback(async () => { try { - const storedDocs = await AsyncStorage.getItem('uploadedDocuments'); + const storedDocs = await AsyncStorage.getItem(STORAGE_KEY); if (storedDocs) { const docs: UploadedDocument[] = JSON.parse(storedDocs); + // Sort by upload date (newest first) docs.sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()); setDocuments(docs); } } catch (error) { console.error('Error loading documents:', error); + Alert.alert('Error', 'Failed to load documents'); } }, []); + // Load documents when screen comes into focus useFocusEffect( useCallback(() => { loadDocuments(); }, [loadDocuments]), ); - // Pick and upload document + // Pick and save document const pickAndUploadDocument = async () => { if (!selectedDocType) { Alert.alert('Document Type Required', 'Please select a document type first'); @@ -61,6 +75,7 @@ export default function DocumentsScreen() { } try { + // Open document picker const result = await DocumentPicker.getDocumentAsync({ type: ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'], copyToCacheDirectory: true, @@ -69,7 +84,8 @@ export default function DocumentsScreen() { if (!result.canceled && result.assets && result.assets.length > 0) { const file = result.assets[0]; - const maxSize = 10 * 1024 * 1024; + // Validate file size (10MB max) + const maxSize = 10 * 1024 * 1024; // 10MB in bytes if (file.size && file.size > maxSize) { Alert.alert('File Too Large', 'Please select a file smaller than 10MB'); return; @@ -77,8 +93,10 @@ export default function DocumentsScreen() { setUploading(true); + // Get document type label const documentTypeInfo = DOCUMENT_TYPES.find((dt) => dt.id === selectedDocType); + // Create new document object const newDocument: UploadedDocument = { id: Date.now().toString(), name: file.name, @@ -88,21 +106,27 @@ export default function DocumentsScreen() { size: file.size || 0, uri: file.uri, uploadedAt: new Date().toISOString(), - verified: false, }; - const storedDocs = await AsyncStorage.getItem('uploadedDocuments'); + // Load existing documents + const storedDocs = await AsyncStorage.getItem(STORAGE_KEY); const allDocs: UploadedDocument[] = storedDocs ? JSON.parse(storedDocs) : []; + + // Add new document allDocs.push(newDocument); - await AsyncStorage.setItem('uploadedDocuments', JSON.stringify(allDocs)); + // Save to AsyncStorage + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(allDocs)); + // Update state (sort by newest first) const sortedDocs = allDocs.sort( (a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime(), ); setDocuments(sortedDocs); + // Reset selection setSelectedDocType(''); + Alert.alert('Success', 'Document uploaded successfully!'); } } catch (err) { @@ -114,7 +138,7 @@ export default function DocumentsScreen() { }; // Delete document - const deleteDocument = (docId: string) => { + const handleDeleteDocument = (doc: UploadedDocument) => { Alert.alert('Delete Document', 'Are you sure you want to delete this document?', [ { text: 'Cancel', style: 'cancel' }, { @@ -122,9 +146,16 @@ export default function DocumentsScreen() { style: 'destructive', onPress: async () => { try { - const updatedDocs = documents.filter((doc) => doc.id !== docId); - await AsyncStorage.setItem('uploadedDocuments', JSON.stringify(updatedDocs)); + // Remove from array + const updatedDocs = documents.filter((d) => d.id !== doc.id); + + // Save to AsyncStorage + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedDocs)); + + // Update state setDocuments(updatedDocs); + + Alert.alert('Success', 'Document deleted'); } catch (error) { console.error('Error deleting document:', error); Alert.alert('Error', 'Failed to delete document'); @@ -134,6 +165,7 @@ export default function DocumentsScreen() { ]); }; + // Format file size const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 Bytes'; const k = 1024; @@ -142,6 +174,7 @@ export default function DocumentsScreen() { return `${Math.round((bytes / Math.pow(k, i)) * 10) / 10} ${sizes[i]}`; }; + // Format date const formatDate = (dateString: string): string => { const date = new Date(dateString); return date.toLocaleDateString('en-US', { @@ -156,13 +189,15 @@ export default function DocumentsScreen() { return ( + {/* Info Card */} - Keep your documents up to date. Upload your licenses and certifications to apply for - shifts. + Keep your documents organized. Upload your licenses and certifications. All documents + are stored locally on your device. + {/* Document Type Selector */} Document Type {showDropdown ? '▲' : '▼'} + {/* Dropdown Menu */} {showDropdown && ( {DOCUMENT_TYPES.map((docType) => ( @@ -204,6 +240,7 @@ export default function DocumentsScreen() { )} + {/* Upload Area */} + {uploading ? ( + + ) : null} - {selectedDocType ? 'Tap to upload' : 'Select a document type first'} + {uploading + ? 'Uploading...' + : selectedDocType + ? 'Tap to upload' + : 'Select a document type first'} PDF, JPG, or PNG up to 10MB + {/* Documents List */} Uploaded Documents ({documents.length}) {documents.length === 0 ? ( + 📄 No documents uploaded yet + + Select a document type above and tap to upload + ) : ( {documents.map((doc) => ( + {/* Icon */} - 📄 + + {doc.type.includes('pdf') ? '📄' : '🖼️'} + + {/* Info */} {doc.name} @@ -244,16 +297,15 @@ export default function DocumentsScreen() { - {doc.verified ? ( - - - Verified - - ) : null} + {/* Local Storage Badge */} + + Local + + {/* Delete Button */} deleteDocument(doc.id)} + onPress={() => handleDeleteDocument(doc)} > @@ -267,11 +319,35 @@ export default function DocumentsScreen() { } const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#F5F7FA' }, - content: { flex: 1, padding: 16 }, - infoCard: { backgroundColor: '#E8EEF7', borderRadius: 12, padding: 16, marginBottom: 24 }, - infoText: { fontSize: 14, color: '#374151', lineHeight: 20 }, - label: { fontSize: 15, fontWeight: '600', color: '#111827', marginBottom: 8 }, + container: { + flex: 1, + backgroundColor: '#F5F7FA', + }, + content: { + flex: 1, + padding: 16, + }, + + // Info Card + infoCard: { + backgroundColor: '#E8EEF7', + borderRadius: 12, + padding: 16, + marginBottom: 24, + }, + infoText: { + fontSize: 14, + color: '#374151', + lineHeight: 20, + }, + + // Document Type Selector + label: { + fontSize: 15, + fontWeight: '600', + color: '#111827', + marginBottom: 8, + }, dropdown: { flexDirection: 'row', alignItems: 'center', @@ -283,9 +359,19 @@ const styles = StyleSheet.create({ padding: 14, marginBottom: 24, }, - dropdownTextPlaceholder: { fontSize: 15, color: '#9CA3AF' }, - dropdownTextSelected: { fontSize: 15, color: '#111827', fontWeight: '500' }, - dropdownIcon: { fontSize: 12, color: '#6B7280' }, + dropdownTextPlaceholder: { + fontSize: 15, + color: '#9CA3AF', + }, + dropdownTextSelected: { + fontSize: 15, + color: '#111827', + fontWeight: '500', + }, + dropdownIcon: { + fontSize: 12, + color: '#6B7280', + }, dropdownMenu: { backgroundColor: '#FFFFFF', borderRadius: 8, @@ -300,10 +386,24 @@ const styles = StyleSheet.create({ elevation: 5, maxHeight: 250, }, - dropdownItem: { padding: 14, borderBottomWidth: 1, borderBottomColor: '#F3F4F6' }, - dropdownItemSelected: { backgroundColor: '#F0F9FF' }, - dropdownItemText: { fontSize: 15, color: '#111827' }, - dropdownItemTextSelected: { color: '#274289', fontWeight: '600' }, + dropdownItem: { + padding: 14, + borderBottomWidth: 1, + borderBottomColor: '#F3F4F6', + }, + dropdownItemSelected: { + backgroundColor: '#F0F9FF', + }, + dropdownItemText: { + fontSize: 15, + color: '#111827', + }, + dropdownItemTextSelected: { + color: '#003f88', + fontWeight: '600', + }, + + // Upload Area uploadArea: { backgroundColor: '#FFFFFF', borderWidth: 2, @@ -314,7 +414,9 @@ const styles = StyleSheet.create({ alignItems: 'center', marginBottom: 32, }, - uploadAreaDisabled: { opacity: 0.5 }, + uploadAreaDisabled: { + opacity: 0.5, + }, uploadIconContainer: { width: 64, height: 64, @@ -324,11 +426,32 @@ const styles = StyleSheet.create({ justifyContent: 'center', marginBottom: 12, }, - uploadIcon: { fontSize: 28, color: '#6B7280' }, - uploadText: { fontSize: 15, fontWeight: '500', color: '#6B7280', marginBottom: 4 }, - uploadSubtext: { fontSize: 13, color: '#9CA3AF' }, - sectionTitle: { fontSize: 16, fontWeight: '700', color: '#111827', marginBottom: 16 }, - documentsList: { gap: 12, paddingBottom: 20 }, + uploadIcon: { + fontSize: 28, + color: '#6B7280', + }, + uploadText: { + fontSize: 15, + fontWeight: '500', + color: '#6B7280', + marginBottom: 4, + }, + uploadSubtext: { + fontSize: 13, + color: '#9CA3AF', + }, + + // Documents List + sectionTitle: { + fontSize: 16, + fontWeight: '700', + color: '#111827', + marginBottom: 16, + }, + documentsList: { + gap: 12, + paddingBottom: 20, + }, documentCard: { flexDirection: 'row', alignItems: 'center', @@ -350,27 +473,81 @@ const styles = StyleSheet.create({ justifyContent: 'center', marginRight: 12, }, - documentIcon: { fontSize: 24 }, - documentInfo: { flex: 1 }, - documentName: { fontSize: 15, fontWeight: '600', color: '#111827', marginBottom: 2 }, - documentType: { fontSize: 13, color: '#274289', marginBottom: 4 }, - documentMeta: { flexDirection: 'row', alignItems: 'center' }, - documentMetaText: { fontSize: 12, color: '#6B7280' }, - documentMetaDot: { fontSize: 12, color: '#6B7280', marginHorizontal: 6 }, - verifiedBadge: { + documentIcon: { + fontSize: 24, + }, + documentInfo: { + flex: 1, + }, + documentName: { + fontSize: 15, + fontWeight: '600', + color: '#111827', + marginBottom: 2, + }, + documentType: { + fontSize: 13, + color: '#003f88', + marginBottom: 4, + }, + documentMeta: { flexDirection: 'row', alignItems: 'center', - backgroundColor: '#D1FAE5', + }, + documentMetaText: { + fontSize: 12, + color: '#6B7280', + }, + documentMetaDot: { + fontSize: 12, + color: '#6B7280', + marginHorizontal: 6, + }, + + // Local Badge + localBadge: { + backgroundColor: '#FEF3C7', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 12, marginRight: 8, - gap: 4, - }, - verifiedIcon: { fontSize: 14, color: '#059669' }, - verifiedText: { fontSize: 12, fontWeight: '600', color: '#059669' }, - deleteButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, - deleteButtonText: { fontSize: 20, color: '#9CA3AF' }, - emptyState: { padding: 40, alignItems: 'center' }, - emptyStateText: { fontSize: 14, color: '#9CA3AF' }, + }, + localBadgeText: { + fontSize: 12, + fontWeight: '600', + color: '#D97706', + }, + + // Delete Button + deleteButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + }, + deleteButtonText: { + fontSize: 20, + color: '#9CA3AF', + }, + + // Empty State + emptyState: { + padding: 40, + alignItems: 'center', + }, + emptyStateIcon: { + fontSize: 48, + marginBottom: 16, + }, + emptyStateText: { + fontSize: 16, + fontWeight: '600', + color: '#6B7280', + marginBottom: 4, + }, + emptyStateSubtext: { + fontSize: 14, + color: '#9CA3AF', + textAlign: 'center', + }, });