Skip to content
Merged
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
32 changes: 32 additions & 0 deletions app-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,38 @@ npm run test

Unit and integration tests are managed via Jest (or Mocha/Chai if used).

## Timesheet API

Generated timesheets are exposed under `/api/v1/timesheets`.

| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| `POST` | `/api/v1/timesheets/generate` | `admin`, `employer`, `guard` | Generate or refresh timesheets for a completed-shift date range. Body requires `startDate` and `endDate` in `YYYY-MM-DD` format. |
| `GET` | `/api/v1/timesheets` | `admin`, `employer`, `guard` | List generated timesheets visible to the current user. Supports optional `startDate`, `endDate`, `guardId`, `page`, and `limit` query parameters. |
| `GET` | `/api/v1/timesheets/:id` | `admin`, `employer`, `guard` | Retrieve one generated timesheet in the current user's scope. |

Timesheet generation only uses shifts that are `completed`, assigned through `acceptedBy`,
and have completed attendance with `guardId`, `shiftId`, `checkInTime`, and `checkOutTime`.
Generation is idempotent: each `shiftId` and `guardId` pair has one timesheet, and repeated
generation updates that record rather than creating duplicates.

RBAC visibility follows the current backend ownership rules:

- Admins can generate and view all timesheets.
- Guards can generate and view only their own timesheets.
- Employers can generate and view timesheets for shifts where `Shift.createdBy` is their user ID.

The standard shift creation flow validates `siteId` against an active `Branch.employerId` owned by
the creating employer, then stores that employer in `Shift.createdBy`. A branch-based ownership
case remains possible for imported/admin-created data: if a shift is attached to a branch owned by
an employer but `createdBy` is a different user, the timesheet scope follows `createdBy` and will
not treat the branch owner as the timesheet employer.

Payable hours are attendance-based. `actualHours` is the raw check-in to check-out duration.
`Shift.breakTime` is stored in minutes and is treated as an unpaid break, so timesheets calculate
`payableHours` as `max(actualHours - breakTime / 60, 0)`. Payable hours are not capped at scheduled
hours, preserving legitimate overtime.

## Local development seed data

The seed commands are for local development only. They refuse production, Atlas/SRV, remote hosts,
Expand Down
37 changes: 37 additions & 0 deletions app-backend/src/controllers/timesheet.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
generateTimesheets,
getTimesheetForUser,
listTimesheetsForUser,
} from '../services/timesheet.service.js';

const sendError = (res, error, fallbackMessage) =>
res.status(error.statusCode || 500).json({
message: error.message || fallbackMessage,
});

export const generateTimesheetsForRange = async (req, res) => {
try {
const result = await generateTimesheets(req.body, req.user);
return res.status(200).json(result);
} catch (error) {
return sendError(res, error, 'Failed to generate timesheets');
}
};

export const listTimesheets = async (req, res) => {
try {
const result = await listTimesheetsForUser(req.query, req.user);
return res.status(200).json(result);
} catch (error) {
return sendError(res, error, 'Failed to retrieve timesheets');
}
};

export const getTimesheetById = async (req, res) => {
try {
const result = await getTimesheetForUser(req.params.id, req.user);
return res.status(200).json(result);
} catch (error) {
return sendError(res, error, 'Failed to retrieve timesheet');
}
};
76 changes: 76 additions & 0 deletions app-backend/src/models/Timesheet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import mongoose from 'mongoose';

const { Schema, model } = mongoose;

const timesheetSchema = new Schema(
{
shiftId: {
type: Schema.Types.ObjectId,
ref: 'Shift',
required: true,
index: true,
},
guardId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true,
},
employerId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true,
},
attendanceId: {
type: Schema.Types.ObjectId,
ref: 'ShiftAttendance',
required: true,
},
shiftDate: {
type: Date,
required: true,
index: true,
},
checkInTime: {
type: Date,
required: true,
},
checkOutTime: {
type: Date,
required: true,
},
scheduledHours: {
type: Number,
required: true,
min: 0,
},
actualHours: {
type: Number,
required: true,
min: 0,
},
payableHours: {
type: Number,
required: true,
min: 0,
},
attendanceBased: {
type: Boolean,
default: true,
},
generatedAt: {
type: Date,
default: Date.now,
},
},
{ timestamps: true }
);

timesheetSchema.index({ shiftId: 1, guardId: 1 }, { unique: true });
timesheetSchema.index({ employerId: 1, shiftDate: -1 });
timesheetSchema.index({ guardId: 1, shiftDate: -1 });

const Timesheet = model('Timesheet', timesheetSchema);

export default Timesheet;
2 changes: 2 additions & 0 deletions app-backend/src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import equipmentRoutes from './equipment.routes.js';
import payrollRoutes from './payroll.routes.js';
import documentRoutes from './document.routes.js';
import emergencyRoutes from "./emergency.routes.js";
import timesheetRoutes from './timesheet.routes.js';
import shiftRequestRoutes from './shiftrequest.routes.js';
const router = express.Router();
router.use('/documents', documentRoutes);
Expand All @@ -32,6 +33,7 @@ router.use('/attendance', shiftAttendanceRoutes);
router.use("/incidents", incidentRoutes);
router.use('/notifications', notificationRoutes);
router.use('/payroll', payrollRoutes);
router.use('/timesheets', timesheetRoutes);
router.use('/shift-requests', shiftRequestRoutes);
router.use('/equipment', equipmentRoutes);
router.use("/emergency", emergencyRoutes);
Expand Down
16 changes: 16 additions & 0 deletions app-backend/src/routes/timesheet.routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import express from 'express';
import auth from '../middleware/auth.js';
import { authorizeRoles } from '../middleware/rbac.js';
import {
generateTimesheetsForRange,
getTimesheetById,
listTimesheets,
} from '../controllers/timesheet.controller.js';

const router = express.Router();

router.post('/generate', auth, authorizeRoles('admin', 'employer', 'guard'), generateTimesheetsForRange);
router.get('/', auth, authorizeRoles('admin', 'employer', 'guard'), listTimesheets);
router.get('/:id', auth, authorizeRoles('admin', 'employer', 'guard'), getTimesheetById);

export default router;
4 changes: 2 additions & 2 deletions app-backend/src/services/payroll.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const getShiftStartDateTime = (shift) => {
return start;
};

const calculateScheduledHours = (shift) => {
export const calculateScheduledHours = (shift) => {
if (!shift?.date || !shift?.startTime || !shift?.endTime) {
return 0;
}
Expand Down Expand Up @@ -144,7 +144,7 @@ const calculateScheduledHours = (shift) => {
return roundHours(hours);
};

const calculateAttendanceHours = (attendance) => {
export const calculateAttendanceHours = (attendance) => {
if (!attendance?.checkInTime || !attendance?.checkOutTime) {
return null;
}
Expand Down
Loading
Loading