diff --git a/app-backend/src/controllers/shiftattendance.controller.js b/app-backend/src/controllers/shiftattendance.controller.js index 4d7ddd810..e9905ffbe 100644 --- a/app-backend/src/controllers/shiftattendance.controller.js +++ b/app-backend/src/controllers/shiftattendance.controller.js @@ -1,4 +1,5 @@ import ShiftAttendance from "../models/ShiftAttendance.js"; +import Shift from "../models/Shift.js"; // Utility: calculate distance using the Haversine formula function calculateDistance(lat1, lon1, lat2, lon2) { @@ -8,8 +9,8 @@ function calculateDistance(lat1, lon1, lat2, lon2) { const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos((lat1 * Math.PI) / 180) * - Math.cos((lat2 * Math.PI) / 180) * - Math.sin(dLon / 2) * Math.sin(dLon / 2); + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; // distance in km } @@ -19,69 +20,65 @@ export const checkIn = async (req, res) => { try { const { latitude, longitude } = req.body; const { shiftId } = req.params; - const guardId = req.user.id; - - // ✅ Validate inputs - if ( - latitude === undefined || - longitude === undefined || - isNaN(latitude) || - isNaN(longitude) - ) { + const guardId = req.user?._id || req.user?.id; + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ message: "Invalid location coordinates" }); + } + + const latNum = Number(latitude); + const lngNum = Number(longitude); + + if (!Number.isFinite(latNum) || !Number.isFinite(lngNum)) { return res.status(400).json({ message: "Invalid location coordinates" }); } - // ✅ Validate shift - const shift = await Shift.findById(shiftId).populate("siteId"); + const shift = await Shift.findById(shiftId); if (!shift) { return res.status(404).json({ message: "Shift not found" }); } - // ✅ Check guard is assigned + const siteLatitude = shift.location?.latitude; + const siteLongitude = shift.location?.longitude; + + if (!Number.isFinite(siteLatitude) || !Number.isFinite(siteLongitude)) { + return res.status(400).json({ message: "Shift location not configured" }); + } + if (String(shift.assignedGuard) !== String(guardId)) { return res.status(403).json({ message: "Not assigned to this shift" }); } - // ✅ Prevent duplicate check-in - const existing = await ShiftAttendance.findOne({ guardId, shiftId }); + const existing = await ShiftAttendance.findOne({ guard: guardId, shift: shiftId }); if (existing) { return res.status(400).json({ message: "Already checked in" }); } - // ✅ Get real site location - const siteLocation = shift.siteId?.location; - if (!siteLocation?.latitude || !siteLocation?.longitude) { - return res.status(400).json({ message: "Site location not configured" }); + const distance = calculateDistance(latNum, lngNum, siteLatitude, siteLongitude); + if (distance > 0.1) { + return res.status(400).json({ message: "Not within shift radius (100m)" }); } - const distance = calculateDistance( - latitude, - longitude, - siteLocation.latitude, - siteLocation.longitude - ); + const [startHour, startMinute] = String(shift.startTime).split(":").map(Number); + const [endHour, endMinute] = String(shift.endTime).split(":").map(Number); - // ✅ Radius check (100m) - if (distance > 0.1) { - return res.status(400).json({ - message: "Not within shift radius (100m)", - }); + const scheduledStart = new Date(shift.date); + scheduledStart.setHours(startHour, startMinute, 0, 0); + + const scheduledEnd = new Date(shift.date); + scheduledEnd.setHours(endHour, endMinute, 0, 0); + + if (scheduledEnd <= scheduledStart) { + scheduledEnd.setDate(scheduledEnd.getDate() + 1); } - // ✅ Save attendance const attendance = new ShiftAttendance({ - guardId, - shiftId, - checkInTime: new Date(), - siteLocation: { - type: "Point", - coordinates: [siteLocation.longitude, siteLocation.latitude], - }, - checkInLocation: { - type: "Point", - coordinates: [longitude, latitude], - }, - locationVerified: true, + shift: shiftId, + guard: guardId, + clockIn: new Date(), + scheduledStart, + scheduledEnd, + recordedBy: guardId, }); await attendance.save(); @@ -90,7 +87,6 @@ export const checkIn = async (req, res) => { message: "Check-in recorded", attendance, }); - } catch (error) { return res.status(500).json({ message: error.message }); } @@ -99,23 +95,47 @@ export const checkIn = async (req, res) => { // POST /api/v1/attendance/checkout/:shiftId export const checkOut = async (req, res) => { try { - console.log("Incoming check-in request:", req.params, req.body); - const { latitude, longitude } = req.body; const { shiftId } = req.params; - const guardId = req.user.id; + const guardId = req.user?._id || req.user?.id; + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ message: "Invalid location coordinates" }); + } - const attendance = await ShiftAttendance.findOne({ guardId, shiftId }); - if (!attendance) + const latNum = Number(latitude); + const lngNum = Number(longitude); + + if (!Number.isFinite(latNum) || !Number.isFinite(lngNum)) { + return res.status(400).json({ message: "Invalid location coordinates" }); + } + + const shift = await Shift.findById(shiftId); + if (!shift) { + return res.status(404).json({ message: "Shift not found" }); + } + + if (String(shift.assignedGuard) !== String(guardId)) { + return res.status(403).json({ message: "Not assigned to this shift" }); + } + + const attendance = await ShiftAttendance.findOne({ guard: guardId, shift: shiftId }); + if (!attendance) { return res.status(404).json({ message: "No check-in record found" }); + } + + if (attendance.clockOut) { + return res.status(400).json({ message: "Already checked out" }); + } + + attendance.clockOut = new Date(); + attendance.recordedBy = guardId; - attendance.checkOutTime = new Date(); - attendance.checkOutLocation = { type: "Point", coordinates: [longitude, latitude] }; await attendance.save(); - res.status(200).json({ message: "Check-out recorded", attendance }); + return res.status(200).json({ message: "Check-out recorded", attendance }); } catch (error) { - res.status(500).json({ message: error.message }); + return res.status(500).json({ message: error.message }); } }; // GET /api/v1/attendance/:userId @@ -123,8 +143,8 @@ export const getAttendanceByUserId = async (req, res) => { try { const { userId } = req.params; - const attendanceRecords = await ShiftAttendance.find({ guardId: userId }) - .sort({ checkInTime: -1 }); + const attendanceRecords = await ShiftAttendance.find({ guard: userId }) + .sort({ clockIn: -1 }); if (!attendanceRecords.length) { return res.status(404).json({ diff --git a/app-backend/src/models/ShiftAttendance.js b/app-backend/src/models/ShiftAttendance.js index 5da3c90c7..64df2064e 100644 --- a/app-backend/src/models/ShiftAttendance.js +++ b/app-backend/src/models/ShiftAttendance.js @@ -1,40 +1,112 @@ -import mongoose from "mongoose"; +import mongoose from 'mongoose'; -const shiftAttendanceSchema = new mongoose.Schema( +const { Schema, model } = mongoose; + +/** + * ShiftAttendance records actual clock-in / clock-out data for a guard on a shift. + * Used by the payroll engine to calculate actual hours worked vs. scheduled hours. + * + * Status lifecycle: + * scheduled → guard assigned but hasn't clocked in yet + * present → guard clocked in AND out (hoursWorked computed) + * incomplete → guard clocked in but never clocked out + * absent → explicitly marked absent (or guard never clocked in) + */ +const shiftAttendanceSchema = new Schema( { - guardId: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", + shift: { + type: Schema.Types.ObjectId, + ref: 'Shift', + required: true, + index: true, + }, + + guard: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + + clockIn: { + type: Date, + default: null, + }, + + clockOut: { + type: Date, + default: null, + }, + + /** Scheduled start (denormalised from Shift for quick queries) */ + scheduledStart: { + type: Date, required: true, }, - shiftId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Shift", + + /** Scheduled end (denormalised from Shift) */ + scheduledEnd: { + type: Date, required: true, }, - siteLocation: { - type: { type: String, enum: ["Point"], default: "Point" }, - coordinates: { - type: [Number], // [longitude, latitude] - required: true, - }, + + /** Computed hours worked; set automatically in pre-save hook */ + hoursWorked: { + type: Number, + default: 0, + min: 0, }, - checkInTime: { type: Date, default: null }, - checkOutTime: { type: Date, default: null }, - checkInLocation: { - type: { type: String, enum: ["Point"], default: "Point" }, - coordinates: { type: [Number], default: [0, 0] }, + + status: { + type: String, + enum: ['scheduled', 'present', 'incomplete', 'absent'], + default: 'scheduled', + index: true, }, - checkOutLocation: { - type: { type: String, enum: ["Point"], default: "Point" }, - coordinates: { type: [Number], default: [0, 0] }, + + notes: { + type: String, + trim: true, + maxlength: 500, + }, + + /** Who recorded this attendance (admin/guard) */ + recordedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + default: null, }, - locationVerified: { type: Boolean, default: false }, }, { timestamps: true } ); -shiftAttendanceSchema.index({ siteLocation: "2dsphere" }); +// Prevent duplicate attendance records per shift/guard pair +shiftAttendanceSchema.index({ shift: 1, guard: 1 }, { unique: true }); + +/** + * Auto-compute hoursWorked and status based on clockIn / clockOut. + */ +shiftAttendanceSchema.pre('save', function (next) { + if (this.clockIn && this.clockOut) { + if (this.clockOut <= this.clockIn) { + return next(new Error('clockOut must be after clockIn')); + } + const diffMs = this.clockOut.getTime() - this.clockIn.getTime(); + this.hoursWorked = Math.round((diffMs / (1000 * 60 * 60)) * 100) / 100; + this.status = 'present'; + } else if (this.clockIn && !this.clockOut) { + this.status = 'incomplete'; + // Partial hours from clock-in to now (capped at scheduled end) + const now = new Date(); + const cap = this.scheduledEnd || now; + const diffMs = Math.min(now.getTime(), cap.getTime()) - this.clockIn.getTime(); + this.hoursWorked = diffMs > 0 ? Math.round((diffMs / (1000 * 60 * 60)) * 100) / 100 : 0; + } else if (!this.clockIn) { + this.status = 'absent'; + this.hoursWorked = 0; + } + next(); +}); -const ShiftAttendance = mongoose.model("ShiftAttendance", shiftAttendanceSchema); -export default ShiftAttendance; +const ShiftAttendance = model('ShiftAttendance', shiftAttendanceSchema); +export default ShiftAttendance; \ No newline at end of file diff --git a/guard_app/package-lock.json b/guard_app/package-lock.json index cf1fae5f2..d481e4a2e 100644 --- a/guard_app/package-lock.json +++ b/guard_app/package-lock.json @@ -3037,7 +3037,7 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "devOptional": true, + "dev": true, "dependencies": { "csstype": "^3.0.2" } @@ -4892,7 +4892,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true + "dev": true }, "node_modules/data-view-buffer": { "version": "1.0.2", @@ -12261,7 +12261,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/guard_app/src/api/attendance.ts b/guard_app/src/api/attendance.ts index e96bc0d97..1109ecd10 100644 --- a/guard_app/src/api/attendance.ts +++ b/guard_app/src/api/attendance.ts @@ -5,6 +5,14 @@ import http from '../lib/http'; export type Attendance = { _id: string; + shift: string | { _id: string }; + guard: string | { _id: string }; + clockIn?: string | null; + clockOut?: string | null; + scheduledStart?: string; + scheduledEnd?: string; + hoursWorked?: number; + status?: string; guardId: string; shiftId: string | any; checkInTime?: string | null; diff --git a/guard_app/src/components/functions/formatAttendanceTime.ts b/guard_app/src/components/functions/formatAttendanceTime.ts new file mode 100644 index 000000000..5550b09fe --- /dev/null +++ b/guard_app/src/components/functions/formatAttendanceTime.ts @@ -0,0 +1,11 @@ +export function formatAttendanceTime(value?: string) { + if (!value) return 'N/A'; + + return new Date(value).toLocaleString('en-AU', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/guard_app/src/components/modal/ShiftDetailsModal.tsx b/guard_app/src/components/modal/ShiftDetailsModal.tsx index e868f0eb5..3bf282e7d 100644 --- a/guard_app/src/components/modal/ShiftDetailsModal.tsx +++ b/guard_app/src/components/modal/ShiftDetailsModal.tsx @@ -7,6 +7,7 @@ import ShiftRequestModal from './ShiftRequestModal'; import type { AllShift, AppliedShift, CompletedShift } from '../../models/Shifts'; import type { AppColors } from '../../theme/colors'; +import { formatAttendanceTime } from '../functions/formatAttendanceTime'; type Props = { shift: AppliedShift | CompletedShift | AllShift | null; @@ -32,6 +33,8 @@ function ShiftDetailsModal({ shift, visible, onClose, colors }: Props) { ? colors.primary : colors.muted; + const hasAttendance = Boolean(shift.attendance?.checkInTime || shift.attendance?.checkOutTime); + return ( @@ -100,6 +103,29 @@ function ShiftDetailsModal({ shift, visible, onClose, colors }: Props) { + {hasAttendance ? ( + + Attendance History + + {shift.attendance?.checkInTime ? ( + + ✅ Checked In + + {formatAttendanceTime(shift.attendance.checkInTime)} + + + ) : null} + + {shift.attendance?.checkOutTime ? ( + + ✅ Checked Out + + {formatAttendanceTime(shift.attendance.checkOutTime)} + + + ) : null} + + ) : null} diff --git a/guard_app/src/models/Shifts.ts b/guard_app/src/models/Shifts.ts index 6699d56db..d0f5b5da2 100644 --- a/guard_app/src/models/Shifts.ts +++ b/guard_app/src/models/Shifts.ts @@ -8,6 +8,10 @@ export type AppliedShift = { date: string; time: string; status?: 'Pending' | 'Confirmed' | 'Rejected'; + attendance?: { + checkInTime?: string; + checkOutTime?: string; + }; }; export type CompletedShift = { @@ -21,6 +25,10 @@ export type CompletedShift = { rated: boolean; rating: number; status?: 'Completed'; + attendance?: { + checkInTime?: string; + checkOutTime?: string; + }; }; export type AllShift = { @@ -32,6 +40,10 @@ export type AllShift = { date: string; time: string; status?: 'Available' | 'Pending' | 'Confirmed'; + attendance?: { + checkInTime?: string; + checkOutTime?: string; + }; }; export type ShiftCardItem = AppliedShift | CompletedShift | AllShift; diff --git a/guard_app/src/screen/HomeScreen.tsx b/guard_app/src/screen/HomeScreen.tsx index 350e7c240..437f9d177 100644 --- a/guard_app/src/screen/HomeScreen.tsx +++ b/guard_app/src/screen/HomeScreen.tsx @@ -22,6 +22,8 @@ import http from '../lib/http'; import { RootStackParamList } from '../navigation/AppNavigator'; import { useAppTheme } from '../theme'; import { AppColors } from '../theme/colors'; +import { showLocalNotification } from '../utils/notificationHelpers'; +import { LocalStorage } from '../lib/localStorage'; type Nav = NativeStackNavigationProp; @@ -132,18 +134,6 @@ export default function HomeScreen() { headerTintColor: colors.white, headerRight: () => ( - navigation.navigate('IncidentReports')} - style={{ paddingHorizontal: 8 }} - > - - - navigation.navigate('Payroll')} - style={{ paddingHorizontal: 8 }} - > - - navigation.navigate('Messages')} style={{ paddingHorizontal: 8 }} @@ -209,6 +199,8 @@ export default function HomeScreen() { setRefreshing(true); await load(); setRefreshing(false); + const token = await LocalStorage.getToken(); + console.log('Refreshed home data. Current token:', token); }; return ( @@ -320,6 +312,16 @@ export default function HomeScreen() { {t('home.noUpcoming')} )} + + +