From fb1c950161413516805572db48e170875707b576 Mon Sep 17 00:00:00 2001 From: 95grooot Date: Thu, 29 Jan 2026 19:28:40 +1100 Subject: [PATCH] sprint 2 tasks Build weekly/monthly calendar integration in Availability tab Implement Document Upload Modal (Functional Only) --- guard_app/src/api/availability.ts | 85 +++ guard_app/src/navigation/AppNavigator.tsx | 1 - guard_app/src/screen/AvailabilityScreen.tsx | 650 +++++++++++++++++++- guard_app/src/screen/DocumentsScreen.tsx | 293 +++++++-- guard_app/src/screen/ShiftsScreen.tsx | 495 +++++++-------- 5 files changed, 1158 insertions(+), 366 deletions(-) diff --git a/guard_app/src/api/availability.ts b/guard_app/src/api/availability.ts index 55c04c33a..3c3706f2f 100644 --- a/guard_app/src/api/availability.ts +++ b/guard_app/src/api/availability.ts @@ -1,5 +1,7 @@ +// src/api/availability.ts import http from '../lib/http'; +// Existing types (keep for backward compatibility) export interface AvailabilityData { userId: string; days: string[]; @@ -13,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 { @@ -43,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 bf20a15e2..d6883cb5c 100644 --- a/guard_app/src/navigation/AppNavigator.tsx +++ b/guard_app/src/navigation/AppNavigator.tsx @@ -8,7 +8,6 @@ import NotificationsScreen from '../screen/notifications'; import SettingsScreen from '../screen/SettingsScreen'; import SignupScreen from '../screen/signupscreen'; import SplashScreen from '../screen/SplashScreen'; - export type RootStackParamList = { AppTabs: undefined; Splash: undefined; diff --git a/guard_app/src/screen/AvailabilityScreen.tsx b/guard_app/src/screen/AvailabilityScreen.tsx index f0f41ad05..f496ddcb6 100644 --- a/guard_app/src/screen/AvailabilityScreen.tsx +++ b/guard_app/src/screen/AvailabilityScreen.tsx @@ -1,22 +1,55 @@ // 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, useCallback, useMemo } from 'react'; +import { + View, + Text, + TouchableOpacity, + ActivityIndicator, + Alert, + StyleSheet, + ScrollView, + Modal, + TextInput, +} from 'react-native'; +import { useFocusEffect } from '@react-navigation/native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { getMe } from '../api/auth'; -import { getAvailability, upsertAvailability, type AvailabilityData } from '../api/availability'; +import { + getAvailability, + upsertAvailability, + addAvailabilitySlot, + getMyAvailabilitySlots, + removeAvailabilitySlot, + type AvailabilityData, + type AvailabilitySlotDto, +} 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'; + +interface CalendarSlot { + id: string; + _id?: string; + date: string; + dayOfWeek: string; + fromTime: string; + toTime: string; + createdAt: string; +} function extractTimeSlots(data: unknown): string[] { if (!data || typeof data !== 'object') return []; @@ -25,41 +58,105 @@ 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 (new) + const [calendarSlots, setCalendarSlots] = useState([]); + 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 mapDtoToCalendarSlot = (dto: AvailabilitySlotDto): CalendarSlot => { + const date = new Date(dto.date); + return { + id: dto._id, + _id: dto._id, + date: dto.date, + dayOfWeek: getDayName(date), + fromTime: dto.fromTime, + toTime: dto.toTime, + createdAt: dto.createdAt, + }; + }; + + // Load both simple and calendar 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); + + // Load calendar slots from storage + const stored = await AsyncStorage.getItem('availability_calendar_slots'); + if (stored) { + setCalendarSlots(JSON.parse(stored)); + } 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)); } + + // Load calendar slots + const slots = await getMyAvailabilitySlots(); + const mapped = slots.map(mapDtoToCalendarSlot); + setCalendarSlots(mapped); + await AsyncStorage.setItem('availability_calendar_slots', JSON.stringify(mapped)); } catch (e) { console.error(e); setError('Failed to load availability'); @@ -71,6 +168,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])); }; @@ -99,7 +197,6 @@ export default function AvailabilityScreen() { return; } - // ✅ MOCK MODE: pretend save succeeded if (DEV_MOCK_AVAILABILITY) { Alert.alert('Success', 'Availability saved (mock)'); return; @@ -107,13 +204,7 @@ export default function AvailabilityScreen() { try { setSaving(true); - - const payload: AvailabilityData = { - userId, - days, - timeSlots, - }; - + const payload: AvailabilityData = { userId, days, timeSlots }; await upsertAvailability(payload); Alert.alert('Success', 'Availability saved'); } catch (e) { @@ -124,16 +215,129 @@ export default function AvailabilityScreen() { } }; - if (loading) { - return ( - - - Loading availability… - - ); - } + // 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 dateStr = formatDate(date); + return calendarSlots.filter((slot) => slot.date === dateStr); + }; - return ( + const handleAddCalendarSlot = async () => { + if (!selectedDate) return; + + if (!fromTime || !toTime) { + Alert.alert('Error', 'Please enter both start and end times'); + return; + } + + if (fromTime >= toTime) { + Alert.alert('Error', 'End time must be after start time'); + return; + } + + try { + setSaving(true); + + if (DEV_MOCK_AVAILABILITY) { + const newSlot: CalendarSlot = { + id: Date.now().toString(), + date: formatDate(selectedDate), + dayOfWeek: getDayName(selectedDate), + fromTime, + toTime, + createdAt: new Date().toISOString(), + }; + + const updated = [...calendarSlots, newSlot]; + await AsyncStorage.setItem('availability_calendar_slots', JSON.stringify(updated)); + setCalendarSlots(updated); + } else { + const result = await addAvailabilitySlot({ + date: formatDate(selectedDate), + fromTime, + toTime, + }); + + const newSlot = mapDtoToCalendarSlot(result as AvailabilitySlotDto); + const updated = [...calendarSlots, newSlot]; + setCalendarSlots(updated); + await AsyncStorage.setItem('availability_calendar_slots', JSON.stringify(updated)); + } + + setShowCalendarModal(false); + setFromTime('09:00'); + setToTime('17:00'); + setSelectedDate(null); + Alert.alert('Success', 'Availability added'); + } catch (e) { + console.error(e); + Alert.alert('Error', 'Failed to add availability'); + } finally { + setSaving(false); + } + }; + + const handleRemoveCalendarSlot = async (slot: CalendarSlot) => { + Alert.alert('Remove', 'Remove this time slot?', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: async () => { + try { + if (!DEV_MOCK_AVAILABILITY && slot._id) { + await removeAvailabilitySlot(slot._id); + } + + const updated = calendarSlots.filter((s) => s.id !== slot.id); + await AsyncStorage.setItem('availability_calendar_slots', JSON.stringify(updated)); + setCalendarSlots(updated); + } catch (e) { + console.error(e); + Alert.alert('Error', 'Failed to remove'); + } + }, + }, + ]); + }; + + // Navigation + const goToPrevious = () => { + if (viewMode === 'weekly') { + setCurrentDate(addDays(currentDate, -7)); + } else if (viewMode === 'monthly') { + setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)); + } + }; + + const goToNext = () => { + if (viewMode === 'weekly') { + setCurrentDate(addDays(currentDate, 7)); + } else if (viewMode === 'monthly') { + setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)); + } + }; + + const goToToday = () => setCurrentDate(new Date()); + + // Render functions + const renderSimpleMode = () => ( {error && {error}} @@ -199,17 +403,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 hasSlot = daySlots.some((slot) => { + const slotHour = parseInt(slot.fromTime.split(':')[0], 10); + const slotEndHour = parseInt(slot.toTime.split(':')[0], 10); + return hour >= slotHour && hour < slotEndHour; + }); + + 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.fromTime} - {slot.toTime} + + 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 +725,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 +732,6 @@ const styles = StyleSheet.create({ marginVertical: 4, }, slotItem: { color: '#111' }, - removeButton: { paddingHorizontal: 10, paddingVertical: 4, @@ -241,10 +741,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 +752,6 @@ const styles = StyleSheet.create({ }, primaryButtonDisabled: { opacity: 0.6 }, primaryButtonText: { color: '#fff', fontWeight: '700' }, - secondaryButton: { flex: 1, borderRadius: 10, @@ -265,6 +762,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 991418884..0770e78fa 100644 --- a/guard_app/src/screen/DocumentsScreen.tsx +++ b/guard_app/src/screen/DocumentsScreen.tsx @@ -1,5 +1,15 @@ +// guard_app/src/screen/DocumentsScreen.tsx + import React, { useState, useCallback } from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native'; +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'; @@ -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', + }, }); diff --git a/guard_app/src/screen/ShiftsScreen.tsx b/guard_app/src/screen/ShiftsScreen.tsx index 023a05685..8233bfa1f 100644 --- a/guard_app/src/screen/ShiftsScreen.tsx +++ b/guard_app/src/screen/ShiftsScreen.tsx @@ -13,16 +13,17 @@ import { Modal, Pressable, Dimensions, + RefreshControl, + Alert, } from 'react-native'; import { myShifts, applyToShift, type ShiftDto } from '../api/shifts'; import { COLORS } from '../theme/colors'; -import { formatDate } from '../utils/date'; const { width } = Dimensions.get('window'); /* -------------------- DEV MOCK (no backend) -------------------- */ -const DEV_MOCK_SHIFTS = __DEV__ && true; +const DEV_MOCK_SHIFTS = false; // Set to true for development, false for production type AppliedShift = { id: string; @@ -248,24 +249,22 @@ function ShiftDetailsModal({ - Requirements: - - - Security License - - - First Aid - - + Requirements: + • Security License + • First Aid Certificate + + + Close + ); } -/* -------------------- Calendar Component -------------------- */ +/* -------------------- Calendar View -------------------- */ function CalendarView({ shifts, @@ -371,11 +370,11 @@ function CalendarView - Accepted + Confirmed - Completed + Rejected @@ -465,30 +464,79 @@ function AppliedTab() { const [q, setQ] = useState(''); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); const [selectedShift, setSelectedShift] = useState(null); const [view, setView] = useState<'list' | 'calendar'>('list'); const fetchData = useCallback(async () => { try { setLoading(true); + if (DEV_MOCK_SHIFTS) { + // Development mode: use mock data setRows(mockApplied); return; } + + // Production mode: fetch from real API const token = await AsyncStorage.getItem('auth_token'); - if (!token) throw new Error('No token'); + if (!token) { + console.warn('No auth token found'); + setRows([]); + return; + } + const decoded = parseJwt(token); - const myUid = decoded?.id; + const myUid = decoded?.id || decoded?.userId; + + if (!myUid) { + console.warn('Could not decode user ID from token'); + setRows([]); + return; + } + + // GET /api/v1/shifts/myshifts const mine = await myShifts(); - setRows(mapMineShifts(mine, myUid)); - } catch { - setRows(mockApplied); + console.log('✅ Fetched shifts from API:', mine.length); + + // Map backend data to frontend format + const mapped = mapMineShifts(mine, myUid); + setRows(mapped); + + // Cache for offline access + await AsyncStorage.setItem('cached_applied_shifts', JSON.stringify(mapped)); + } catch (error) { + console.error('❌ Error fetching applied shifts:', error); + + // Fallback to cached data + try { + const cached = await AsyncStorage.getItem('cached_applied_shifts'); + if (cached) { + console.log('📦 Using cached applied shifts'); + setRows(JSON.parse(cached)); + } else { + setRows([]); + } + } catch (cacheError) { + console.error('Error reading cache:', cacheError); + setRows([]); + } } finally { setLoading(false); } }, []); - useFocusEffect(useCallback(() => void fetchData(), [fetchData])); + useFocusEffect( + useCallback(() => { + void fetchData(); + }, [fetchData]), + ); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await fetchData(); + setRefreshing(false); + }, [fetchData]); const filtered = rows.filter((r) => `${r.title}${r.company}${r.site}`.toLowerCase().includes(q.toLowerCase()), @@ -510,7 +558,7 @@ function AppliedTab() { - {loading && } + {loading && !refreshing && } {view === 'calendar' ? ( @@ -523,6 +571,13 @@ function AppliedTab() { setSelectedShift(item)} /> )} ListEmptyComponent={No shifts found} + refreshControl={ + + } /> )} @@ -541,26 +596,63 @@ function CompletedTab() { const [q, setQ] = useState(''); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); const [selectedShift, setSelectedShift] = useState(null); const [view, setView] = useState<'list' | 'calendar'>('list'); const fetchData = useCallback(async () => { try { setLoading(true); + if (DEV_MOCK_SHIFTS) { + // Development mode: use mock data setRows(mockCompleted); return; } + + // Production mode: fetch from real API + // GET /api/v1/shifts/myshifts?status=past const resp = await myShifts('past'); - setRows(mapCompleted(resp)); - } catch { - setRows(mockCompleted); + console.log('✅ Fetched completed shifts from API:', resp.length); + + // Map backend data to frontend format + const mapped = mapCompleted(resp); + setRows(mapped); + + // Cache for offline access + await AsyncStorage.setItem('cached_completed_shifts', JSON.stringify(mapped)); + } catch (error) { + console.error('❌ Error fetching completed shifts:', error); + + // Fallback to cached data + try { + const cached = await AsyncStorage.getItem('cached_completed_shifts'); + if (cached) { + console.log('📦 Using cached completed shifts'); + setRows(JSON.parse(cached)); + } else { + setRows([]); + } + } catch (cacheError) { + console.error('Error reading cache:', cacheError); + setRows([]); + } } finally { setLoading(false); } }, []); - useFocusEffect(useCallback(() => void fetchData(), [fetchData])); + useFocusEffect( + useCallback(() => { + void fetchData(); + }, [fetchData]), + ); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await fetchData(); + setRefreshing(false); + }, [fetchData]); const filtered = rows.filter((r) => `${r.title}${r.company}${r.site}`.toLowerCase().includes(q.toLowerCase()), @@ -582,7 +674,7 @@ function CompletedTab() { - {loading && } + {loading && !refreshing && } {view === 'calendar' ? ( setSelectedShift(item)} /> )} ListEmptyComponent={No completed shifts found} + refreshControl={ + + } /> )} @@ -628,7 +727,7 @@ export default function ShiftsScreen() { tabBarIndicatorStyle: { backgroundColor: COLORS.primary, height: '100%', - borderRadius: 12, + borderRadius: 10, }, tabBarLabelStyle: { fontWeight: '700', @@ -648,18 +747,12 @@ export default function ShiftsScreen() { /* -------------------- Styles -------------------- */ const s = StyleSheet.create({ - screen: { - flex: 1, - backgroundColor: '#F5F7FA', - paddingHorizontal: 16, - paddingTop: 12, - }, - - // Search Bar + screen: { flex: 1, backgroundColor: '#F5F7FA' }, searchRow: { flexDirection: 'row', alignItems: 'center', - marginBottom: 16, + paddingHorizontal: 16, + paddingVertical: 12, gap: 8, }, searchContainer: { @@ -667,319 +760,169 @@ const s = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', backgroundColor: '#FFFFFF', - borderRadius: 8, + borderRadius: 10, paddingHorizontal: 12, - paddingVertical: 10, - borderWidth: 1, - borderColor: '#E5E7EB', + paddingVertical: 8, }, - searchIcon: { - fontSize: 16, - marginRight: 8, - }, - searchInput: { - flex: 1, - fontSize: 14, - color: '#111827', - }, - - // View Toggle + searchIcon: { fontSize: 16, marginRight: 8 }, + searchInput: { flex: 1, fontSize: 15, color: '#111827' }, viewToggle: { flexDirection: 'row', backgroundColor: '#FFFFFF', - borderRadius: 8, - borderWidth: 1, - borderColor: '#E5E7EB', - overflow: 'hidden', + borderRadius: 10, + padding: 4, }, viewToggleBtn: { - width: 44, - height: 44, + width: 36, + height: 36, alignItems: 'center', justifyContent: 'center', + borderRadius: 8, }, - viewToggleBtnActive: { - backgroundColor: COLORS.primary, - }, - viewToggleIcon: { - fontSize: 18, - color: '#6B7280', - }, - viewToggleIconActive: { - color: '#FFFFFF', + viewToggleBtnActive: { backgroundColor: COLORS.primary }, + viewToggleIcon: { fontSize: 18 }, + viewToggleIconActive: { opacity: 1 }, + emptyText: { + textAlign: 'center', + color: '#9CA3AF', + marginTop: 40, + fontSize: 15, }, - // Shift Card + // Card card: { backgroundColor: '#FFFFFF', + marginHorizontal: 16, + marginVertical: 6, borderRadius: 12, padding: 16, - marginBottom: 12, - borderWidth: 1, - borderColor: '#E5E7EB', - }, - cardHeader: { - marginBottom: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 2, }, + cardHeader: { marginBottom: 8 }, cardTitleSection: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, - cardTitle: { - fontSize: 16, - fontWeight: '700', - color: '#111827', - flex: 1, - }, + cardTitle: { fontSize: 16, fontWeight: '700', color: '#111827', flex: 1 }, cardStatusBadge: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, }, - cardStatusText: { - fontSize: 11, - fontWeight: '600', - color: '#FFFFFF', - }, - cardCompany: { - fontSize: 13, - color: '#6B7280', - marginBottom: 12, - }, - cardRow: { - flexDirection: 'row', - marginBottom: 6, - }, - cardLabel: { - fontSize: 13, - color: '#6B7280', - width: 60, - }, - cardValue: { - fontSize: 13, - color: '#111827', - fontWeight: '500', - }, - cardPay: { - fontSize: 13, - color: '#10B981', - fontWeight: '700', - }, + cardStatusText: { fontSize: 12, fontWeight: '600', color: '#FFFFFF' }, + cardCompany: { fontSize: 14, color: '#6B7280', marginBottom: 12 }, + cardRow: { flexDirection: 'row', marginBottom: 4 }, + cardLabel: { fontSize: 14, color: '#6B7280', width: 60 }, + cardValue: { fontSize: 14, color: '#111827', flex: 1 }, + cardPay: { fontSize: 14, color: COLORS.primary, fontWeight: '600', flex: 1 }, // Calendar - calendarContainer: { - backgroundColor: '#FFFFFF', - borderRadius: 12, - padding: 16, - marginBottom: 16, - borderWidth: 1, - borderColor: '#E5E7EB', - }, + calendarContainer: { flex: 1 }, calHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 16, - }, - calMonthText: { - fontSize: 16, - fontWeight: '700', - color: '#111827', - }, - calNavButtons: { - flexDirection: 'row', - gap: 8, + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: '#FFFFFF', }, + calMonthText: { fontSize: 18, fontWeight: '700', color: '#111827' }, + calNavButtons: { flexDirection: 'row', gap: 8 }, calNavBtn: { width: 32, height: 32, - backgroundColor: '#F3F4F6', - borderRadius: 6, alignItems: 'center', justifyContent: 'center', + backgroundColor: '#F3F4F6', + borderRadius: 8, }, - calNavBtnText: { - fontSize: 18, - fontWeight: '600', - color: '#374151', - }, + calNavBtnText: { fontSize: 20, fontWeight: '600', color: COLORS.primary }, calWeekHeader: { flexDirection: 'row', - marginBottom: 8, - }, - calWeekCell: { - flex: 1, - alignItems: 'center', - }, - calWeekText: { - fontSize: 12, - fontWeight: '600', - color: '#6B7280', - }, - calGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 4, + backgroundColor: '#F9FAFB', + borderTopWidth: 1, + borderBottomWidth: 1, + borderColor: '#E5E7EB', }, + calWeekCell: { flex: 1, alignItems: 'center', paddingVertical: 8 }, + calWeekText: { fontSize: 12, fontWeight: '600', color: '#6B7280' }, + calGrid: { flexDirection: 'row', flexWrap: 'wrap', backgroundColor: '#FFFFFF' }, calDayCell: { - width: (width - 32 - 16 * 2 - 24) / 7, + width: width / 7, aspectRatio: 1, - borderRadius: 8, - borderWidth: 1, - borderColor: '#E5E7EB', alignItems: 'center', justifyContent: 'center', + borderWidth: 0.5, + borderColor: '#E5E7EB', padding: 4, }, - calDayCellDim: { - opacity: 0.3, - }, - calDayNumber: { - fontSize: 13, - fontWeight: '600', - color: '#111827', - }, - calDayNumberDim: { - color: '#9CA3AF', - }, - calShiftIndicators: { - flexDirection: 'row', - gap: 3, - marginTop: 4, - }, - calShiftDot: { - width: 6, - height: 6, - borderRadius: 3, - }, + calDayCellDim: { opacity: 0.3 }, + calDayNumber: { fontSize: 14, fontWeight: '600', color: '#111827', marginBottom: 4 }, + calDayNumberDim: { color: '#9CA3AF' }, + calShiftIndicators: { flexDirection: 'row', gap: 2 }, + calShiftDot: { width: 6, height: 6, borderRadius: 3 }, calLegend: { flexDirection: 'row', justifyContent: 'center', gap: 16, - marginTop: 16, - paddingTop: 16, - borderTopWidth: 1, - borderTopColor: '#E5E7EB', - }, - calLegendItem: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - calLegendDot: { - width: 10, - height: 10, - borderRadius: 5, - }, - calLegendText: { - fontSize: 12, - color: '#6B7280', + paddingVertical: 12, + backgroundColor: '#F9FAFB', }, + calLegendItem: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + calLegendDot: { width: 10, height: 10, borderRadius: 5 }, + calLegendText: { fontSize: 12, color: '#6B7280' }, // Modal modalOverlay: { flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.7)', + backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center', }, modalContent: { - backgroundColor: '#2D3748', - width: width - 48, + backgroundColor: '#FFFFFF', borderRadius: 16, - padding: 20, + width: width * 0.9, + maxHeight: '80%', }, modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 20, - }, - modalTitle: { - fontSize: 18, - fontWeight: '700', - color: '#FFFFFF', - }, - modalCloseBtn: { - width: 28, - height: 28, - alignItems: 'center', - justifyContent: 'center', - }, - modalCloseText: { - fontSize: 20, - color: '#9CA3AF', - }, - modalBody: { - gap: 12, + padding: 20, + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', }, + modalTitle: { fontSize: 20, fontWeight: '700', color: '#111827' }, + modalCloseBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, + modalCloseText: { fontSize: 24, color: '#6B7280' }, + modalBody: { padding: 20 }, modalTitleRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 12, - }, - modalShiftTitle: { - fontSize: 16, - fontWeight: '700', - color: '#FFFFFF', - flex: 1, - }, - statusBadge: { - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 12, - }, - statusBadgeText: { - fontSize: 12, - fontWeight: '600', - color: '#FFFFFF', - }, - modalDetail: { - flexDirection: 'row', - justifyContent: 'space-between', - }, - modalLabel: { - fontSize: 14, - color: '#9CA3AF', - }, - modalValue: { - fontSize: 14, - fontWeight: '500', - color: '#FFFFFF', - }, - modalRequirements: { - marginTop: 12, - paddingTop: 16, - borderTopWidth: 1, - borderTopColor: '#4A5568', - }, - modalRequirementsTitle: { - fontSize: 14, - fontWeight: '600', - color: '#FFFFFF', - marginBottom: 12, - }, - modalTags: { - flexDirection: 'row', - gap: 8, - }, - modalTag: { - backgroundColor: '#4A5568', - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 6, - }, - modalTagText: { - fontSize: 12, - color: '#E5E7EB', + marginBottom: 16, }, - - emptyText: { - textAlign: 'center', - color: '#9CA3AF', - marginTop: 40, - fontSize: 14, + modalShiftTitle: { fontSize: 18, fontWeight: '700', color: '#111827', flex: 1 }, + statusBadge: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 12 }, + statusBadgeText: { fontSize: 12, fontWeight: '600', color: '#FFFFFF' }, + modalDetail: { flexDirection: 'row', marginBottom: 12 }, + modalLabel: { fontSize: 14, fontWeight: '600', color: '#6B7280', width: 80 }, + modalValue: { fontSize: 14, color: '#111827', flex: 1 }, + modalRequirements: { marginTop: 8 }, + modalReqText: { fontSize: 14, color: '#111827', marginLeft: 12, marginTop: 4 }, + modalCloseButton: { + backgroundColor: COLORS.primary, + margin: 20, + marginTop: 0, + paddingVertical: 14, + borderRadius: 10, + alignItems: 'center', }, + modalCloseButtonText: { fontSize: 16, fontWeight: '700', color: '#FFFFFF' }, });