diff --git a/guard_app/src/components/ShiftDetailsModal.tsx b/guard_app/src/components/ShiftDetailsModal.tsx new file mode 100644 index 00000000..86ba603b --- /dev/null +++ b/guard_app/src/components/ShiftDetailsModal.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +export interface ShiftDetailsModalProps { + visible: boolean; + shift: unknown; + onClose: () => void; +} + +export default function ShiftDetailsModal({ visible, shift, onClose }: ShiftDetailsModalProps) { + if (!shift || typeof shift !== 'object') return null; + + const s = shift as any; + + const statusColor = + s.status === 'Confirmed' + ? '#22c55e' + : s.status === 'Pending' + ? '#3b82f6' + : '#9ca3af'; + + return ( + + + + + + {s.title ?? 'Shift Details'} + + + {s.date} · {s.time} + + + {s.site && {s.site}} + {s.rate && {s.rate}} + + Status: {s.status} + + + {s.status === 'applied' && ( + + Cancel Application + + )} + + + Close + + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + justifyContent: 'center', + padding: 16, + }, + card: { + backgroundColor: 'white', + borderRadius: 12, + padding: 16, + }, + statusPill: { + height: 6, + width: 48, + borderRadius: 4, + alignSelf: 'center', + marginBottom: 12, + }, + title: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 8, + }, + text: { + marginBottom: 4, + }, + status: { + marginTop: 8, + fontWeight: '600', + }, + buttonsRow: { + flexDirection: 'row', + marginTop: 16, + justifyContent: 'flex-end', + gap: 8, + }, + primaryButton: { + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + backgroundColor: '#003f88', + }, + primaryButtonText: { + color: '#fff', + fontWeight: '600', + }, + secondaryButton: { + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + borderWidth: 1, + borderColor: '#ccc', + backgroundColor: '#f5f5f5', + }, + secondaryButtonText: { + color: '#333', + fontWeight: '500', + }, +}); diff --git a/guard_app/src/screen/IncidentReportScreen.tsx b/guard_app/src/screen/IncidentReportScreen.tsx new file mode 100644 index 00000000..0009730d --- /dev/null +++ b/guard_app/src/screen/IncidentReportScreen.tsx @@ -0,0 +1,207 @@ +import React, { useState } from 'react'; + +import { + View, + Text, + StyleSheet, + TextInput, + TouchableOpacity, + Alert, + ScrollView, + Image, + ActivityIndicator, +} from 'react-native'; +import * as ImagePicker from 'expo-image-picker'; +import { COLORS } from '../theme/colors'; + +type Severity = 'Low' | 'Medium' | 'High'; + +/* --- date & time */ +const getNowDateTime = () => new Date().toISOString().slice(0, 16).replace('T', ' '); + +export default function IncidentReportScreen() { + const [description, setDescription] = useState(''); + const [severity, setSeverity] = useState(null); + const [images, setImages] = useState([]); + const [submitting, setSubmitting] = useState(false); + + /* --- auto date & time (CHANGED) --- */ + const [dateTime] = useState(getNowDateTime()); + + /* Pick image */ + const pickImage = async () => { + const res = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 0.7, + allowsMultipleSelection: true, + }); + + if (!res.canceled) { + setImages((prev) => [...prev, ...res.assets.map((a) => a.uri)]); + } + }; + + /* Submit (mocked) */ + const submitReport = async () => { + if (!description.trim() || !severity) { + Alert.alert('Missing fields', 'Please fill all required fields.'); + return; + } + + setSubmitting(true); + + // simulate API call + setTimeout(() => { + setSubmitting(false); + Alert.alert('Success', 'Incident report submitted successfully.'); + + // reset form + setDescription(''); + setSeverity(null); + setImages([]); + }, 1200); + }; + + return ( + + Incident Report + + {/* Description */} + Incident Description * + + + {/* Date & Time (CHANGED) */} + Date & Time * + {dateTime} + + {/* Severity */} + Severity * + + {(['Low', 'Medium', 'High'] as Severity[]).map((lvl) => ( + setSeverity(lvl)} + > + + {lvl} + + + ))} + + + {/* Photos */} + Photos (optional) + + Add Photos + + + + {images.map((uri) => ( + + ))} + + + {/* Submit */} + + {submitting ? ( + + ) : ( + Submit Report + )} + + + ); +} + +const s = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: COLORS.bg, + padding: 16, + }, + title: { + fontSize: 22, + fontWeight: '800', + marginBottom: 16, + color: COLORS.text, + }, + label: { + fontSize: 14, + fontWeight: '600', + marginTop: 12, + marginBottom: 6, + color: COLORS.text, + }, + textArea: { + height: 140, + backgroundColor: '#fff', + borderRadius: 12, + borderWidth: 1, + borderColor: '#E5E7EB', + padding: 12, + textAlignVertical: 'top', + }, + input: { + backgroundColor: '#fff', + padding: 12, + borderRadius: 12, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + readOnly: { + backgroundColor: '#F3F4F6', + padding: 12, + borderRadius: 12, + color: '#6B7280', + }, + row: { + flexDirection: 'row', + gap: 8, + }, + severityBtn: { + flex: 1, + paddingVertical: 10, + borderRadius: 10, + backgroundColor: '#E5E7EB', + alignItems: 'center', + }, + severitySelected: { + backgroundColor: COLORS.primary, + }, + photoBtn: { + backgroundColor: COLORS.primary, + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + }, + preview: { + width: 70, + height: 70, + borderRadius: 8, + marginRight: 8, + }, + submitBtn: { + marginTop: 24, + backgroundColor: COLORS.primary, + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + }, + submitText: { + color: '#fff', + fontWeight: '700', + fontSize: 16, + }, +}); diff --git a/guard_app/src/screen/ShiftsScreen.tsx b/guard_app/src/screen/ShiftsScreen.tsx index 2268e7ca..40bd2753 100644 --- a/guard_app/src/screen/ShiftsScreen.tsx +++ b/guard_app/src/screen/ShiftsScreen.tsx @@ -78,89 +78,82 @@ export default function ShiftsScreen() { const onApply = async (shiftId: string) => { try { - const res = await applyToShift(shiftId); - Alert.alert('Applied', res?.message ?? 'Applied successfully ✅'); - await fetchShifts(); + // optimistic: set pending + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, status: 'Pending' } : r))); + + const res = await applyToShift(id); // { message, shift } + const newStatus = (res?.shift?.status ?? '').toString().toLowerCase(); + + // trust backend-mapped status ('pending' for guard) + setRows((prev) => + prev.map((r) => + r.id === id + ? { + ...r, + status: + newStatus === 'pending' + ? 'Pending' + : newStatus === 'confirmed' + ? 'Confirmed' + : newStatus === 'rejected' + ? 'Rejected' + : r.status, + } + : r, + ), + ); + + Alert.alert('Applied', 'Your application has been sent.'); + // optional: background refresh later, not immediately + // await fetchData(); } catch (e: any) { - const statusCode = e?.response?.status; - const backendMsg = e?.response?.data?.message; - const msg = backendMsg ?? e?.message ?? 'Failed to apply'; - - if (statusCode === 403) { - Alert.alert( - 'Forbidden', - 'Forbidden: insufficient permissions.\n\nYou might be logged in with a non-guard role. Log out, clear app storage, and login as a guard.', - ); - return; - } - - Alert.alert('Error', msg); + setErr(e?.response?.data?.message ?? e?.message ?? 'Failed to apply'); + // rollback on error + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, status: undefined } : r))); } }; const renderItem = ({ item }: { item: ShiftDto }) => { const status = safeStatus(item.status); - return ( - - navigation.navigate('ShiftDetails', { - shift: item, - refresh: fetchShifts, - }) - } - > - - - {item.title} - - - {status.toUpperCase()} - - - - {item.createdBy?.company ?? 'Company N/A'} - - - {formatDatePretty(item.date)} - {formatTimeRange(item.startTime, item.endTime) - ? ` • ${formatTimeRange(item.startTime, item.endTime)}` - : ''} - - - - ${item.payRate ?? 0}/hr - - {status === 'open' ? ( - onApply(item._id)}> - Apply - - ) : ( - - {status === 'applied' - ? 'Applied' - : status === 'assigned' - ? 'Assigned' - : status === 'completed' - ? 'Completed' - : status} - - )} - - - ); - }; + {loading && } + {err && !loading && {err}} + {!loading && !err && filtered.length === 0 && ( + No shifts found. + )} - if (loading) { - return ( - - - Loading shifts... - - ); - } + i.id} + contentContainerStyle={{ paddingBottom: 24 }} + renderItem={({ item }) => ( + { + setSelectedShift(item); + setDetailsVisible(true); + }} + > + onApply(item.id) : undefined} + > + + Status: + {item.status ?? 'Available'} + + + {formatDate(item.date)} + + {item.time} + + + + )} + /> return ( @@ -172,53 +165,229 @@ export default function ShiftsScreen() { refreshControl={} ListEmptyComponent={No shifts found.} /> + + setDetailsVisible(false)} + /> ); } -const s = StyleSheet.create({ - container: { flex: 1, backgroundColor: COLORS.bg }, +function Stars({ n }: { n: number }) { + return ( + + {'★'.repeat(n)} + {'☆'.repeat(5 - n)} + + ); +} + +/* --- Completed Tab --- */ +function CompletedTab() { + const [q, setQ] = useState(''); + const [showFilters, setShowFilters] = useState(false); + const [filters, setFilters] = useState({ + status: null, + company: [] as string[], + site: [] as string[], + }); + + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setErr(null); + + const resp = await myShifts('past'); + const completedMapped = mapCompleted(resp); + setRows(completedMapped); + } catch (e: any) { + setErr(e?.response?.data?.message ?? e?.message ?? 'Failed to load shifts'); + } finally { + setLoading(false); + } + }, []); + + useFocusEffect( + useCallback(() => { + fetchData(); + }, [fetchData]), + ); + + useFocusEffect( + useCallback(() => { + fetchData(); + }, [fetchData]), + ); + + const filtered = filterShifts(rows, q, filters); center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, loadingText: { marginTop: 10, color: COLORS.muted }, - empty: { textAlign: 'center', marginTop: 30, color: COLORS.muted }, + {loading && } + {err && !loading && {err}} + {!loading && !err && filtered.length === 0 && ( + No completed shifts yet. + )} - card: { - backgroundColor: '#fff', - borderRadius: 14, - padding: 16, - marginBottom: 12, - elevation: 2, - }, + i.id} + contentContainerStyle={{ paddingBottom: 24 }} + renderItem={({ item }) => ( + + + + {item.title} + {item.company} + {item.site} + + {item.rate} + + + + Status: + + Completed {item.rated ? '(Rated)' : '(Unrated)'} + + + + + + {formatDate(item.date)} + + {item.time} + + + )} + /> rowBetween: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, title: { fontSize: 18, fontWeight: '800', color: COLORS.text, flex: 1, paddingRight: 10 }, - badge: { - fontSize: 12, - fontWeight: '800', - paddingHorizontal: 10, - paddingVertical: 6, - borderRadius: 999, - overflow: 'hidden', +export default function ShiftScreen() { + return ( + ({ + tabBarAccessibilityLabel: `${route.name} tab`, + tabBarStyle: { + backgroundColor: '#E7E7EB', + borderRadius: 12, + marginHorizontal: 12, + marginTop: 8, + overflow: 'hidden', // keeps indicator rounded + }, + tabBarIndicatorStyle: { + backgroundColor: '#274289', // blue background + height: '100%', // fill the tab height + borderRadius: 12, + }, + tabBarLabelStyle: { + fontWeight: '700', + textTransform: 'none', + }, + tabBarActiveTintColor: '#fff', // white text when active + tabBarInactiveTintColor: '#000', // black text when inactive + })} + > + + + + ); +} + +/* Styles */ +const s = StyleSheet.create({ + screen: { flex: 1, backgroundColor: COLORS.bg, paddingHorizontal: 16, paddingTop: 8 }, + + searchRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 }, + search: { + flex: 1, + backgroundColor: '#fff', + borderRadius: 12, + height: 44, + paddingHorizontal: 12, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + filterBtn: { + marginLeft: 8, + width: 40, + height: 44, + borderRadius: 10, + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#E5E7EB', + alignItems: 'center', + justifyContent: 'center', + }, + filterText: { + fontSize: 18, + color: COLORS.text, + }, + applyBtnText: { color: '#fff', fontWeight: '800' }, + + card: { + backgroundColor: COLORS.card, + borderRadius: 14, + padding: 12, + marginVertical: 8, + elevation: 2, + shadowColor: '#000', + shadowOpacity: 0.05, + shadowRadius: 8, + }, + headerRow: { flexDirection: 'row', alignItems: 'center' }, + title: { fontSize: 16, fontWeight: '800', color: COLORS.text }, + muted: { color: COLORS.muted }, + rate: { fontSize: 15, fontWeight: '800', color: COLORS.rate }, + + row: { flexDirection: 'row', alignItems: 'center', marginTop: 6 }, + rowSpace: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 6, }, - badgeOpen: { backgroundColor: '#E8F0FF', color: COLORS.primary }, - badgeOther: { backgroundColor: '#F2F2F2', color: '#444' }, + dot: { color: COLORS.muted }, - subText: { color: COLORS.muted, marginTop: 6 }, + status: { marginTop: 6, color: COLORS.muted }, - pay: { fontWeight: '800', color: COLORS.text, marginTop: 12 }, + // Filter Menu Styles + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + modalContent: { width: '90%', backgroundColor: '#fff', padding: 20, borderRadius: 12 }, + modalTitle: { fontSize: 18, fontWeight: 'bold', marginBottom: 10 }, + modalLabel: { marginTop: 10, fontWeight: '600' }, + tag: { padding: 8, marginRight: 6, backgroundColor: '#eee', borderRadius: 20 }, + tagSelected: { backgroundColor: COLORS.primary }, + modalCloseBtn: { + marginTop: 20, + backgroundColor: COLORS.primary, + padding: 10, + borderRadius: 8, + alignItems: 'center', + }, + modalCloseText: { color: '#fff', fontWeight: 'bold' }, + // Apply applyBtn: { - marginTop: 12, + marginTop: 10, backgroundColor: COLORS.primary, - paddingVertical: 10, - paddingHorizontal: 18, borderRadius: 10, + paddingVertical: 10, + alignItems: 'center', }, - applyBtnText: { color: '#fff', fontWeight: '800' }, - - dimText: { marginTop: 12, color: COLORS.muted, fontWeight: '700' }, + applyText: { color: '#fff', fontWeight: '700' }, });