Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 76 additions & 56 deletions app-backend/src/controllers/shiftattendance.controller.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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
}
Expand All @@ -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();
Expand All @@ -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 });
}
Expand All @@ -99,32 +95,56 @@ 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
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({
Expand Down
124 changes: 98 additions & 26 deletions app-backend/src/models/ShiftAttendance.js
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 3 additions & 3 deletions guard_app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions guard_app/src/api/attendance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@

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;

Check warning on line 17 in guard_app/src/api/attendance.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type

Check warning on line 17 in guard_app/src/api/attendance.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type
checkInTime?: string | null;
checkOutTime?: string | null;
locationVerified: boolean;
Expand Down
11 changes: 11 additions & 0 deletions guard_app/src/components/functions/formatAttendanceTime.ts
Original file line number Diff line number Diff line change
@@ -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',
});
}
Loading
Loading