From cfe7a8b3f7455ffc6b9855a87ffa05f31611f57c Mon Sep 17 00:00:00 2001 From: William Yu Jackson Date: Sun, 25 Jan 2026 19:12:29 +1100 Subject: [PATCH 1/5] Add shift details modal and incident report creator --- .../src/components/ShiftDetailsModal.tsx | 138 +++++++++++ guard_app/src/navigation/AppTabs.tsx | 3 + guard_app/src/screen/IncidentReportScreen.tsx | 223 ++++++++++++++++++ guard_app/src/screen/ShiftsScreen.tsx | 106 ++++++--- 4 files changed, 438 insertions(+), 32 deletions(-) create mode 100644 guard_app/src/components/ShiftDetailsModal.tsx create mode 100644 guard_app/src/screen/IncidentReportScreen.tsx diff --git a/guard_app/src/components/ShiftDetailsModal.tsx b/guard_app/src/components/ShiftDetailsModal.tsx new file mode 100644 index 000000000..facead9e4 --- /dev/null +++ b/guard_app/src/components/ShiftDetailsModal.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +// types + component +export interface ShiftDetailsModalProps { + visible: boolean; + shift: any; + onClose: () => void; +} + +export default function ShiftDetailsModal({ + visible, + shift, + onClose, +}: ShiftDetailsModalProps) { + if (!shift) return null; + + const statusColor = + shift.status === 'Confirmed' + ? '#22c55e' + : shift.status === 'Pending' + ? '#3b82f6' + : '#9ca3af'; + + + return ( + + + + + + + {shift.title ?? 'Shift Details'} + + + + {shift.date} · {shift.time} + + + + {shift.site && ( + {shift.site} + )} + + {shift.rate && ( + {shift.rate} + )} + + + + Status: {shift.status} + + + + {shift.status === 'applied' && ( + + + Cancel Application + + + )} + + + Close + + + + + + ); +} + +// styles +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/navigation/AppTabs.tsx b/guard_app/src/navigation/AppTabs.tsx index 7f9076196..6dee45110 100644 --- a/guard_app/src/navigation/AppTabs.tsx +++ b/guard_app/src/navigation/AppTabs.tsx @@ -3,11 +3,13 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import HomeScreen from '../screen/HomeScreen'; import ProfileScreen from '../screen/ProfileScreen'; +import IncidentReportScreen from '../screen/IncidentReportScreen'; import ShiftsScreen from '../screen/ShiftsScreen'; export type AppTabParamList = { Home: undefined; Shifts: undefined; + Incident: undefined; Profile: undefined; }; @@ -33,6 +35,7 @@ export default function AppTabs() { > + ); diff --git a/guard_app/src/screen/IncidentReportScreen.tsx b/guard_app/src/screen/IncidentReportScreen.tsx new file mode 100644 index 000000000..6810ce06d --- /dev/null +++ b/guard_app/src/screen/IncidentReportScreen.tsx @@ -0,0 +1,223 @@ +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 DateTimePicker from '@react-native-community/datetimepicker'; +import { COLORS } from '../theme/colors'; + +type Severity = 'Low' | 'Medium' | 'High'; + +export default function IncidentReportScreen() { + const [description, setDescription] = useState(''); + const [date, setDate] = useState(new Date()); + const [showDatePicker, setShowDatePicker] = useState(false); + const [severity, setSeverity] = useState(null); + const [images, setImages] = useState([]); + const [submitting, setSubmitting] = useState(false); + + /* 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([]); + setDate(new Date()); + }, 1200); + }; + + return ( + + Incident Report + + {/* Description */} + Incident Description * + + + {/* Date & Time */} + Date & Time * + setShowDatePicker(true)} + > + {date.toLocaleString()} + + + {showDatePicker && ( + { + setShowDatePicker(false); + if (selected) setDate(selected); + }} + /> + )} + + {/* 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', + }, + 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 cd735ef49..a6968d0c9 100644 --- a/guard_app/src/screen/ShiftsScreen.tsx +++ b/guard_app/src/screen/ShiftsScreen.tsx @@ -1,3 +1,6 @@ +import { useEffect } from 'react'; + +import ShiftDetailsModal from '../components/ShiftDetailsModal'; import React, { useState, useCallback } from 'react'; import { View, Text, TextInput, FlatList, TouchableOpacity, StyleSheet, Modal, Pressable, ActivityIndicator, Alert, @@ -262,11 +265,32 @@ function Card({ /* --- Applied tab --- */ function AppliedTab() { + const [selectedShift, setSelectedShift] = useState(null); + const [detailsVisible, setDetailsVisible] = useState(false); + const [q, setQ] = useState(''); const [showFilters, setShowFilters] = useState(false); const [filters, setFilters] = useState({ status: null as null | 'Pending' | 'Confirmed' | 'Rejected', company: [] as string[], site: [] as string[] }); const [rows, setRows] = useState([]); + + +// // testing +// useEffect(() => { +// setRows([ +// { +// id: 'demo', +// title: 'Security Guard', +// company: 'Demo Co', +// site: 'Melbourne CBD', +// rate: '$35 p/h', +// date: '2026-01-20', +// time: '18:00 - 02:00', +// status: 'Rejected', +// }, +// ]); +// }, []); + const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); @@ -275,7 +299,6 @@ function AppliedTab() { setLoading(true); setErr(null); - // Fetch User Data const token = await AsyncStorage.getItem("auth_token"); if (!token) throw new Error("No auth token found in storage"); @@ -292,7 +315,6 @@ function AppliedTab() { const myIds = new Set(mineMapped.map(m => m.id)); const globalMapped = mapGlobalShifts(allResp.items, myIds); - // dedupe by ID → prefer myShifts if exists const merged: AppliedShift[] = []; const seen = new Set(); @@ -311,10 +333,6 @@ function AppliedTab() { } }, []); - - - useFocusEffect(useCallback(() => { fetchData(); }, [fetchData])); - const colorFor = (st?: AppliedShift['status']) => { if (!st) return COLORS.link; // available if (st === 'Pending') return COLORS.status.pending; @@ -363,35 +381,53 @@ function AppliedTab() { keyExtractor={i => i.id} contentContainerStyle={{ paddingBottom: 24 }} renderItem={({ item }) => ( - onApply(item.id) : undefined} + { + setSelectedShift(item); + setDetailsVisible(true); + }} > - - Status: - {item.status ?? 'Available'} - - - {formatDate(item.date)} - - {item.time} - - + onApply(item.id) : undefined} + > + + Status: + + {item.status ?? 'Available'} + + + + {formatDate(item.date)} + + {item.time} + + + )} - /> - setShowFilters(false)} - filters={filters} - setFilters={setFilters} - data={rows} /> - - ); + + setShowFilters(false)} + filters={filters} + setFilters={setFilters} + data={rows} + /> + + setDetailsVisible(false)} + /> + + ); + } function Stars({ n }: { n: number }) { @@ -415,7 +451,6 @@ function CompletedTab() { 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'); @@ -424,6 +459,13 @@ function CompletedTab() { } }, []); +useFocusEffect( + useCallback(() => { + fetchData(); + }, [fetchData]) +); + + useFocusEffect(useCallback(() => { fetchData(); }, [fetchData])); From 835624bbfe09a7b75a67941866968bc7a7f11f2e Mon Sep 17 00:00:00 2001 From: William Yu Jackson Date: Tue, 27 Jan 2026 16:44:48 +1100 Subject: [PATCH 2/5] Format and finalise shift details modal and incident reporting UI --- .../src/components/ShiftDetailsModal.tsx | 44 +-- guard_app/src/navigation/AppTabs.tsx | 2 +- guard_app/src/screen/IncidentReportScreen.tsx | 26 +- guard_app/src/screen/ShiftsScreen.tsx | 341 +++++++++++------- 4 files changed, 237 insertions(+), 176 deletions(-) diff --git a/guard_app/src/components/ShiftDetailsModal.tsx b/guard_app/src/components/ShiftDetailsModal.tsx index facead9e4..49af828a0 100644 --- a/guard_app/src/components/ShiftDetailsModal.tsx +++ b/guard_app/src/components/ShiftDetailsModal.tsx @@ -8,64 +8,38 @@ export interface ShiftDetailsModalProps { onClose: () => void; } -export default function ShiftDetailsModal({ - visible, - shift, - onClose, -}: ShiftDetailsModalProps) { +export default function ShiftDetailsModal({ visible, shift, onClose }: ShiftDetailsModalProps) { if (!shift) return null; const statusColor = - shift.status === 'Confirmed' - ? '#22c55e' - : shift.status === 'Pending' - ? '#3b82f6' - : '#9ca3af'; - + shift.status === 'Confirmed' ? '#22c55e' : shift.status === 'Pending' ? '#3b82f6' : '#9ca3af'; return ( - + - - {shift.title ?? 'Shift Details'} - + {shift.title ?? 'Shift Details'} {shift.date} · {shift.time} + {shift.site && {shift.site}} - {shift.site && ( - {shift.site} - )} - - {shift.rate && ( - {shift.rate} - )} + {shift.rate && {shift.rate}} - - - Status: {shift.status} - + Status: {shift.status} {shift.status === 'applied' && ( - - Cancel Application - + Cancel Application )} - + Close diff --git a/guard_app/src/navigation/AppTabs.tsx b/guard_app/src/navigation/AppTabs.tsx index 6dee45110..eef601e56 100644 --- a/guard_app/src/navigation/AppTabs.tsx +++ b/guard_app/src/navigation/AppTabs.tsx @@ -35,7 +35,7 @@ export default function AppTabs() { > - + ); diff --git a/guard_app/src/screen/IncidentReportScreen.tsx b/guard_app/src/screen/IncidentReportScreen.tsx index 6810ce06d..dd1c8fca7 100644 --- a/guard_app/src/screen/IncidentReportScreen.tsx +++ b/guard_app/src/screen/IncidentReportScreen.tsx @@ -33,7 +33,7 @@ export default function IncidentReportScreen() { }); if (!res.canceled) { - setImages(prev => [...prev, ...res.assets.map(a => a.uri)]); + setImages((prev) => [...prev, ...res.assets.map((a) => a.uri)]); } }; @@ -75,10 +75,7 @@ export default function IncidentReportScreen() { {/* Date & Time */} Date & Time * - setShowDatePicker(true)} - > + setShowDatePicker(true)}> {date.toLocaleString()} @@ -96,13 +93,10 @@ export default function IncidentReportScreen() { {/* Severity */} Severity * - {(['Low', 'Medium', 'High'] as Severity[]).map(lvl => ( + {(['Low', 'Medium', 'High'] as Severity[]).map((lvl) => ( setSeverity(lvl)} > Photos (optional) - - Add Photos - + Add Photos - {images.map(uri => ( + {images.map((uri) => ( ))} {/* Submit */} - + {submitting ? ( ) : ( diff --git a/guard_app/src/screen/ShiftsScreen.tsx b/guard_app/src/screen/ShiftsScreen.tsx index a6968d0c9..3d73a7059 100644 --- a/guard_app/src/screen/ShiftsScreen.tsx +++ b/guard_app/src/screen/ShiftsScreen.tsx @@ -3,7 +3,16 @@ import { useEffect } from 'react'; import ShiftDetailsModal from '../components/ShiftDetailsModal'; import React, { useState, useCallback } from 'react'; import { - View, Text, TextInput, FlatList, TouchableOpacity, StyleSheet, Modal, Pressable, ActivityIndicator, Alert, + View, + Text, + TextInput, + FlatList, + TouchableOpacity, + StyleSheet, + Modal, + Pressable, + ActivityIndicator, + Alert, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; @@ -21,8 +30,8 @@ function parseJwt(token: string) { const jsonPayload = decodeURIComponent( atob(base64) .split('') - .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) - .join('') + .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join(''), ); return JSON.parse(jsonPayload); } catch (e) { @@ -30,21 +39,31 @@ function parseJwt(token: string) { } } - type AppliedShift = { - id: string; title: string; company: string; site: string; - rate: string; date: string; time: string; + id: string; + title: string; + company: string; + site: string; + rate: string; + date: string; + time: string; status?: 'Pending' | 'Confirmed' | 'Rejected'; }; type CompletedShift = { - id: string; title: string; company: string; site: string; - rate: string; date: string; time: string; - rated: boolean; rating: number; + id: string; + title: string; + company: string; + site: string; + rate: string; + date: string; + time: string; + rated: boolean; + rating: number; }; /* To rate helper to backend */ -const toRate = (r?: number | string) => typeof r === 'number' ? `$${r} p/h` : (r ?? '$—'); +const toRate = (r?: number | string) => (typeof r === 'number' ? `$${r} p/h` : (r ?? '$—')); /* Show Apply only for shifts that have not been applied for */ const canApply = (st?: AppliedShift['status']) => !st; // only show if status is undefined/null @@ -53,12 +72,15 @@ const canApply = (st?: AppliedShift['status']) => !st; // only show if status is function mapMineShifts(s: ShiftDto[] | unknown, myUid: string): AppliedShift[] { const arr = Array.isArray(s) ? s : []; return arr - .filter(x => x.status !== 'completed') - .map(x => { + .filter((x) => x.status !== 'completed') + .map((x) => { let status: AppliedShift['status'] | undefined; - const acceptedId = typeof x.acceptedBy === 'object' ? x.acceptedBy?._id : String(x.acceptedBy ?? ''); - const applicants = Array.isArray(x.applicants) ? x.applicants.map(a => (typeof a === 'object' ? a._id : String(a))) : []; + const acceptedId = + typeof x.acceptedBy === 'object' ? x.acceptedBy?._id : String(x.acceptedBy ?? ''); + const applicants = Array.isArray(x.applicants) + ? x.applicants.map((a) => (typeof a === 'object' ? a._id : String(a))) + : []; if (x.status === 'assigned' && acceptedId === myUid) { status = 'Confirmed'; @@ -72,7 +94,9 @@ function mapMineShifts(s: ShiftDto[] | unknown, myUid: string): AppliedShift[] { id: x._id, title: x.title, company: x.createdBy?.company ?? '—', - site: x.location ? `${x.location.suburb ?? ''} ${x.location.state ?? ''}`.trim() || '—' : '—', + site: x.location + ? `${x.location.suburb ?? ''} ${x.location.state ?? ''}`.trim() || '—' + : '—', rate: typeof x.payRate === 'number' ? `$${x.payRate} p/h` : (x.payRate ?? '$—'), date: x.date, time: `${x.startTime} - ${x.endTime}`, @@ -85,9 +109,9 @@ function mapMineShifts(s: ShiftDto[] | unknown, myUid: string): AppliedShift[] { function mapGlobalShifts(s: ShiftDto[] | unknown, myIds: Set): AppliedShift[] { const arr = Array.isArray(s) ? s : []; return arr - .filter(x => ['open', 'applied'].includes((x.status ?? 'open').toLowerCase())) - .filter(x => !myIds.has(x._id)) - .map(x => ({ + .filter((x) => ['open', 'applied'].includes((x.status ?? 'open').toLowerCase())) + .filter((x) => !myIds.has(x._id)) + .map((x) => ({ id: x._id, title: x.title, company: x.createdBy?.company ?? '—', @@ -103,8 +127,8 @@ function mapGlobalShifts(s: ShiftDto[] | unknown, myIds: Set): AppliedSh function mapCompleted(s: ShiftDto[] | unknown): CompletedShift[] { const arr = Array.isArray(s) ? s : []; return arr - .filter(x => x.status === 'completed') - .map(x => ({ + .filter((x) => x.status === 'completed') + .map((x) => ({ id: x._id, title: x.title, company: x.createdBy?.company ?? '—', @@ -117,9 +141,6 @@ function mapCompleted(s: ShiftDto[] | unknown): CompletedShift[] { })); } - - - // FilterModal with FlatList function FilterModal({ visible, onClose, filters, setFilters, data }) { const toggleStatus = (status) => { @@ -127,10 +148,10 @@ function FilterModal({ visible, onClose, filters, setFilters, data }) { }; const toggleItem = (field: 'company' | 'site', item: string) => { - setFilters(prev => { + setFilters((prev) => { const current = prev[field]; const updated = current.includes(item) - ? current.filter(i => i !== item) + ? current.filter((i) => i !== item) : [...current, item]; return { ...prev, [field]: updated }; }); @@ -156,7 +177,8 @@ function FilterModal({ visible, onClose, filters, setFilters, data }) { toggleStatus(status)}> + onPress={() => toggleStatus(status)} + > {status} ))} @@ -206,10 +228,13 @@ function FilterModal({ visible, onClose, filters, setFilters, data }) { } function filterShifts(data, q, filters) { - return data.filter(x => { - const qMatch = (x.title + x.createdBy?.company + x.site).toLowerCase().includes(q.toLowerCase()); + return data.filter((x) => { + const qMatch = (x.title + x.createdBy?.company + x.site) + .toLowerCase() + .includes(q.toLowerCase()); const statusMatch = !filters.status || x.status === filters.status; - const companyMatch = filters.company.length === 0 || filters.company.includes(x.createdBy?.company); + const companyMatch = + filters.company.length === 0 || filters.company.includes(x.createdBy?.company); const siteMatch = filters.site.length === 0 || filters.site.includes(x.site); return qMatch && statusMatch && companyMatch && siteMatch; }); @@ -234,15 +259,26 @@ function Search({ q, setQ, onFilterPress }) { accessibilityRole="button" accessibilityLabel="Open filter options" > - + ); } function Card({ - title, company, site, rate, children, onApply, -}: React.PropsWithChildren<{ title: string; company: string; site: string; rate: string; onApply?: () => void }>) { + title, + company, + site, + rate, + children, + onApply, +}: React.PropsWithChildren<{ + title: string; + company: string; + site: string; + rate: string; + onApply?: () => void; +}>) { return ( @@ -265,31 +301,34 @@ function Card({ /* --- Applied tab --- */ function AppliedTab() { - const [selectedShift, setSelectedShift] = useState(null); - const [detailsVisible, setDetailsVisible] = useState(false); + const [selectedShift, setSelectedShift] = useState(null); + const [detailsVisible, setDetailsVisible] = useState(false); const [q, setQ] = useState(''); const [showFilters, setShowFilters] = useState(false); - const [filters, setFilters] = useState({ status: null as null | 'Pending' | 'Confirmed' | 'Rejected', company: [] as string[], site: [] as string[] }); + const [filters, setFilters] = useState({ + status: null as null | 'Pending' | 'Confirmed' | 'Rejected', + company: [] as string[], + site: [] as string[], + }); const [rows, setRows] = useState([]); - -// // testing -// useEffect(() => { -// setRows([ -// { -// id: 'demo', -// title: 'Security Guard', -// company: 'Demo Co', -// site: 'Melbourne CBD', -// rate: '$35 p/h', -// date: '2026-01-20', -// time: '18:00 - 02:00', -// status: 'Rejected', -// }, -// ]); -// }, []); + // // testing + // useEffect(() => { + // setRows([ + // { + // id: 'demo', + // title: 'Security Guard', + // company: 'Demo Co', + // site: 'Melbourne CBD', + // rate: '$35 p/h', + // date: '2026-01-20', + // time: '18:00 - 02:00', + // status: 'Rejected', + // }, + // ]); + // }, []); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); @@ -299,26 +338,23 @@ function AppliedTab() { setLoading(true); setErr(null); - const token = await AsyncStorage.getItem("auth_token"); - if (!token) throw new Error("No auth token found in storage"); + const token = await AsyncStorage.getItem('auth_token'); + if (!token) throw new Error('No auth token found in storage'); const decoded = parseJwt(token); const myUid = decoded?.id; - if (!myUid) throw new Error("No user ID in token"); + if (!myUid) throw new Error('No user ID in token'); - const [mine, allResp] = await Promise.all([ - myShifts(), - listShifts() - ]); + const [mine, allResp] = await Promise.all([myShifts(), listShifts()]); const mineMapped = mapMineShifts(mine, myUid); - const myIds = new Set(mineMapped.map(m => m.id)); + const myIds = new Set(mineMapped.map((m) => m.id)); const globalMapped = mapGlobalShifts(allResp.items, myIds); const merged: AppliedShift[] = []; const seen = new Set(); - [...mineMapped, ...globalMapped].forEach(shift => { + [...mineMapped, ...globalMapped].forEach((shift) => { if (!seen.has(shift.id)) { seen.add(shift.id); merged.push(shift); @@ -345,18 +381,29 @@ function AppliedTab() { const onApply = async (id: string) => { try { // optimistic: set pending - setRows(prev => prev.map(r => r.id === id ? { ...r, status: 'Pending' } : r)); + 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 - )); + 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 @@ -364,7 +411,7 @@ function AppliedTab() { } catch (e: any) { 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)); + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, status: undefined } : r))); } }; @@ -374,11 +421,13 @@ function AppliedTab() { {loading && } {err && !loading && {err}} - {!loading && !err && filtered.length === 0 && No shifts found.} + {!loading && !err && filtered.length === 0 && ( + No shifts found. + )} i.id} + keyExtractor={(i) => i.id} contentContainerStyle={{ paddingBottom: 24 }} renderItem={({ item }) => ( Status: - - {item.status ?? 'Available'} - + {item.status ?? 'Available'} {formatDate(item.date)} @@ -409,36 +456,43 @@ function AppliedTab() { )} - /> - setShowFilters(false)} - filters={filters} - setFilters={setFilters} - data={rows} - /> - - setDetailsVisible(false)} - /> - - ); + setShowFilters(false)} + filters={filters} + setFilters={setFilters} + data={rows} + /> + setDetailsVisible(false)} + /> + + ); } function Stars({ n }: { n: number }) { - return {'★'.repeat(n)}{'☆'.repeat(5 - n)}; + 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 [filters, setFilters] = useState({ + status: null, + company: [] as string[], + site: [] as string[], + }); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); @@ -459,15 +513,17 @@ function CompletedTab() { } }, []); -useFocusEffect( - useCallback(() => { - fetchData(); - }, [fetchData]) -); - - + useFocusEffect( + useCallback(() => { + fetchData(); + }, [fetchData]), + ); - useFocusEffect(useCallback(() => { fetchData(); }, [fetchData])); + useFocusEffect( + useCallback(() => { + fetchData(); + }, [fetchData]), + ); const filtered = filterShifts(rows, q, filters); @@ -477,11 +533,13 @@ useFocusEffect( {loading && } {err && !loading && {err}} - {!loading && !err && filtered.length === 0 && No completed shifts yet.} + {!loading && !err && filtered.length === 0 && ( + No completed shifts yet. + )} i.id} + keyExtractor={(i) => i.id} contentContainerStyle={{ paddingBottom: 24 }} renderItem={({ item }) => ( @@ -496,7 +554,9 @@ useFocusEffect( Status: - Completed {item.rated ? '(Rated)' : '(Unrated)'} + + Completed {item.rated ? '(Rated)' : '(Unrated)'} + @@ -529,23 +589,23 @@ export default function ShiftScreen() { screenOptions={({ route }) => ({ tabBarAccessibilityLabel: `${route.name} tab`, tabBarStyle: { - backgroundColor: "#E7E7EB", + backgroundColor: '#E7E7EB', borderRadius: 12, marginHorizontal: 12, marginTop: 8, overflow: 'hidden', // keeps indicator rounded }, tabBarIndicatorStyle: { - backgroundColor: "#274289", // blue background - height: '100%', // fill the tab height + backgroundColor: '#274289', // blue background + height: '100%', // fill the tab height borderRadius: 12, }, tabBarLabelStyle: { - fontWeight: "700", - textTransform: "none", + fontWeight: '700', + textTransform: 'none', }, - tabBarActiveTintColor: "#fff", // white text when active - tabBarInactiveTintColor: "#000", // black text when inactive + tabBarActiveTintColor: '#fff', // white text when active + tabBarInactiveTintColor: '#000', // black text when inactive })} > @@ -554,19 +614,30 @@ export default function ShiftScreen() { ); } - /* 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', + 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', + marginLeft: 8, + width: 40, + height: 44, + borderRadius: 10, + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#E5E7EB', + alignItems: 'center', + justifyContent: 'center', }, filterText: { fontSize: 18, @@ -574,8 +645,14 @@ const s = StyleSheet.create({ }, card: { - backgroundColor: COLORS.card, borderRadius: 14, padding: 12, marginVertical: 8, - elevation: 2, shadowColor: '#000', shadowOpacity: 0.05, shadowRadius: 8, + 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 }, @@ -583,22 +660,44 @@ const s = StyleSheet.create({ 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 }, + rowSpace: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 6, + }, dot: { color: COLORS.muted }, status: { marginTop: 6, color: COLORS.muted }, // Filter Menu Styles - modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center' }, + 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' }, + modalCloseBtn: { + marginTop: 20, + backgroundColor: COLORS.primary, + padding: 10, + borderRadius: 8, + alignItems: 'center', + }, modalCloseText: { color: '#fff', fontWeight: 'bold' }, // Apply - applyBtn: { marginTop: 10, backgroundColor: COLORS.primary, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }, + applyBtn: { + marginTop: 10, + backgroundColor: COLORS.primary, + borderRadius: 10, + paddingVertical: 10, + alignItems: 'center', + }, applyText: { color: '#fff', fontWeight: '700' }, -}); \ No newline at end of file +}); From 6fb8b9147561f5fcc65ec4cd2decaa596354c622 Mon Sep 17 00:00:00 2001 From: William Yu Jackson Date: Tue, 27 Jan 2026 17:14:45 +1100 Subject: [PATCH 3/5] Add shift details modal and incident reporting UI --- guard_app/src/screen/IncidentReportScreen.tsx | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/guard_app/src/screen/IncidentReportScreen.tsx b/guard_app/src/screen/IncidentReportScreen.tsx index dd1c8fca7..0009730d4 100644 --- a/guard_app/src/screen/IncidentReportScreen.tsx +++ b/guard_app/src/screen/IncidentReportScreen.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; + import { View, Text, @@ -11,19 +12,22 @@ import { ActivityIndicator, } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; -import DateTimePicker from '@react-native-community/datetimepicker'; 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 [date, setDate] = useState(new Date()); - const [showDatePicker, setShowDatePicker] = useState(false); 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({ @@ -55,7 +59,6 @@ export default function IncidentReportScreen() { setDescription(''); setSeverity(null); setImages([]); - setDate(new Date()); }, 1200); }; @@ -73,22 +76,9 @@ export default function IncidentReportScreen() { style={s.textArea} /> - {/* Date & Time */} + {/* Date & Time (CHANGED) */} Date & Time * - setShowDatePicker(true)}> - {date.toLocaleString()} - - - {showDatePicker && ( - { - setShowDatePicker(false); - if (selected) setDate(selected); - }} - /> - )} + {dateTime} {/* Severity */} Severity * @@ -170,6 +160,12 @@ const s = StyleSheet.create({ borderWidth: 1, borderColor: '#E5E7EB', }, + readOnly: { + backgroundColor: '#F3F4F6', + padding: 12, + borderRadius: 12, + color: '#6B7280', + }, row: { flexDirection: 'row', gap: 8, From b34d8d66c3efd95a9a853b7b4e49a4494f32a8b2 Mon Sep 17 00:00:00 2001 From: jacksonwilliamyu Date: Tue, 27 Jan 2026 17:52:11 +1100 Subject: [PATCH 4/5] Update ShiftDetailsModal.tsx --- .../src/components/ShiftDetailsModal.tsx | 67 ++++++++++++++----- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/guard_app/src/components/ShiftDetailsModal.tsx b/guard_app/src/components/ShiftDetailsModal.tsx index 49af828a0..4a4964096 100644 --- a/guard_app/src/components/ShiftDetailsModal.tsx +++ b/guard_app/src/components/ShiftDetailsModal.tsx @@ -1,46 +1,84 @@ import React from 'react'; -import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { + Modal, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; -// types + component export interface ShiftDetailsModalProps { visible: boolean; shift: any; onClose: () => void; } -export default function ShiftDetailsModal({ visible, shift, onClose }: ShiftDetailsModalProps) { - if (!shift) return null; +export default function ShiftDetailsModal( + { visible, shift, onClose }: ShiftDetailsModalProps, +) { + if (!shift) { + return null; + } const statusColor = - shift.status === 'Confirmed' ? '#22c55e' : shift.status === 'Pending' ? '#3b82f6' : '#9ca3af'; + shift.status === 'Confirmed' + ? '#22c55e' + : shift.status === 'Pending' + ? '#3b82f6' + : '#9ca3af'; return ( - + - + - {shift.title ?? 'Shift Details'} + + {shift.title ?? 'Shift Details'} + {shift.date} · {shift.time} - {shift.site && {shift.site}} + {shift.site && ( + {shift.site} + )} - {shift.rate && {shift.rate}} + {shift.rate && ( + {shift.rate} + )} - Status: {shift.status} + + Status: {shift.status} + {shift.status === 'applied' && ( - Cancel Application + + Cancel Application + )} - - Close + + + Close + @@ -49,7 +87,6 @@ export default function ShiftDetailsModal({ visible, shift, onClose }: ShiftDeta ); } -// styles const styles = StyleSheet.create({ backdrop: { flex: 1, From a8955d0c5214206402de1f071041b154f866548a Mon Sep 17 00:00:00 2001 From: jacksonwilliamyu Date: Tue, 27 Jan 2026 17:54:51 +1100 Subject: [PATCH 5/5] Refactor ShiftDetailsModal for better type safety --- .../src/components/ShiftDetailsModal.tsx | 76 +++++-------------- 1 file changed, 21 insertions(+), 55 deletions(-) diff --git a/guard_app/src/components/ShiftDetailsModal.tsx b/guard_app/src/components/ShiftDetailsModal.tsx index 4a4964096..86ba603b3 100644 --- a/guard_app/src/components/ShiftDetailsModal.tsx +++ b/guard_app/src/components/ShiftDetailsModal.tsx @@ -1,84 +1,50 @@ import React from 'react'; -import { - Modal, - StyleSheet, - Text, - TouchableOpacity, - View, -} from 'react-native'; +import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; export interface ShiftDetailsModalProps { visible: boolean; - shift: any; + shift: unknown; onClose: () => void; } -export default function ShiftDetailsModal( - { visible, shift, onClose }: ShiftDetailsModalProps, -) { - if (!shift) { - return null; - } +export default function ShiftDetailsModal({ visible, shift, onClose }: ShiftDetailsModalProps) { + if (!shift || typeof shift !== 'object') return null; + + const s = shift as any; const statusColor = - shift.status === 'Confirmed' + s.status === 'Confirmed' ? '#22c55e' - : shift.status === 'Pending' - ? '#3b82f6' - : '#9ca3af'; + : s.status === 'Pending' + ? '#3b82f6' + : '#9ca3af'; return ( - + - + - - {shift.title ?? 'Shift Details'} - + {s.title ?? 'Shift Details'} - {shift.date} · {shift.time} + {s.date} · {s.time} - {shift.site && ( - {shift.site} - )} + {s.site && {s.site}} + {s.rate && {s.rate}} - {shift.rate && ( - {shift.rate} - )} - - - Status: {shift.status} - + Status: {s.status} - {shift.status === 'applied' && ( + {s.status === 'applied' && ( - - Cancel Application - + Cancel Application )} - - - Close - + + Close