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