Timesheet — daily hours logging with submit → approve/reject
The whole timesheet model reduces to one idea: a
Timesheetis one (employee, day) row recordingregularHours+overtimeHours, walked through a four-state lifecycle (DRAFT → SUBMITTED → APPROVED | REJECTED) by bulk status mutations. It is a self-contained log: it has no foreign keys to attendance, shifts, or projects, and the only downstream consumer is payroll, which sumsovertimeHoursofAPPROVEDrows to compute the overtime pay component.
Source: BE src/modules/hr/timesheet · Admin src/modules/hr/timesheet
1. Purpose & scope
The timesheet module owns the manual daily-hours ledger per employee. It is responsible for:
- Recording, per employee per day, the
regularHoursandovertimeHoursworked plus a free-textdescriptionof work done. - A lightweight approval lifecycle driven by bulk status mutations (
submitTimesheets,approveTimesheets,rejectTimesheets) operating on arrays of entry ids. - Status-gated mutability: only
DRAFT/REJECTEDentries can be edited/submitted; onlyDRAFTcan be deleted;SUBMITTED/APPROVEDare locked from edit/delete. - Exposing approved overtime hours to payroll and a completion metric to the HR dashboard.
It explicitly does not:
- Link to attendance, shifts, or timetables. There is no
attendanceId, no clock-in/out derivation, no biometric tie. Hours are entered by hand. (Confirmed: no attendance/shift import in the timesheet module; grep finds zero references.) - Track projects / tasks / cost centers. There is no
projectIdor task field. "Project time tracking" is not implemented — the only context is the free-textdescription. Flag. - Use the workflow-approval-engine. Unlike leave/claim/advance/loan (which route through the generic approval orchestrator), timesheet approval is a bare status flip done directly in its own repository via
updateMany. TheapproverIdfield is informational only — it is never read to route or gate the approval. Flag (see §9). - Compute regular-hours pay. Payroll reads only
overtimeHours;regularHoursis informational/reporting only (used for the dashboard completion metric and the admin "Total Hrs" column).
2. Data model
2.1 hr_timesheets collection
Schema: hr/timesheet/timesheet.schema.ts (class Timesheet extends BaseSchema, decorated @HrSchema({ collection: "hr_timesheets" }) — hr_ prefixed, unlike employees). Soft-delete via mongoose-delete (deletedAt: true).
BaseSchema contributes the common envelope: _id, companyId, branchId (multi-tenant scoping), ref, documentCode, documentDate, createdAt/By, updatedAt/By, deleted, canUpdate/View/Delete/Post, client. All *Id setters coerce 24-char hex strings to ObjectId via BaseSchema.toObjectId.
| field | type | required | description |
|---|---|---|---|
employeeId |
ObjectId → employees |
yes | The employee the hours belong to. Indexed { employeeId: 1, date: 1 }. |
date |
number (unix ms) | yes | Start-of-day timestamp. Normalized server-side: createTimesheet does new Date(input.date); setHours(0,0,0,0). Indexed with employeeId. |
regularHours |
number (default 0) |
— | Regular hours worked that day. Informational/reporting only — not consumed by payroll. |
overtimeHours |
number (default 0) |
— | Overtime hours. The only field payroll reads (sum over APPROVED rows → overtime pay). |
description |
string (default "") |
— | Free-text "work done" note. The only place "what was worked on" is captured. |
status |
enum TimesheetStatus (default DRAFT) |
— | Lifecycle state. Indexed { status: 1 }. GraphQL-registered enum. |
approverId |
ObjectId → employees |
— | Optional intended approver chosen at create/edit. Informational only — never used to route or gate approval. Flag. |
approvedById |
ObjectId → (user) | — | Who actually approved. Set by approveEntries to the caller (approvedById arg = the logged-in user._id, see §6). |
approvedAt |
number (unix ms) | — | When approved. Set to Date.now() by approveEntries. |
rejectionReason |
string (default "") |
— | Reason captured on reject (rejectEntries). |
Multi-tenancy:
companyIdis copied from the employee at create time (createTimesheetsetscompanyId: employee.companyId), not from request context. The repository'sbuildQueryadds an ESS self-scoping rule: if the caller is an employee (contextSvc.employeeIdset), the query is force-filtered toemployeeId: contextSvc.employeeId— so a logged-in employee only ever sees their own entries (see §5, §9).
2.2 Enum
// timesheet.schema.ts (also mirrored in admin model.ts and GraphQL)
export enum TimesheetStatus {
DRAFT = "DRAFT", // default; editable + deletable
SUBMITTED = "SUBMITTED", // locked; awaiting approve/reject
APPROVED = "APPROVED", // locked; counted by payroll
REJECTED = "REJECTED", // editable + re-submittable
} // ← registered in GraphQL (timesheet.dto.ts)2.3 Indexes
TimesheetSchema.index({ employeeId: 1, date: 1 });
TimesheetSchema.index({ status: 1 });3. API surface
GraphQL (timesheet.resolver.ts)
All operations are guarded by @ApGqlAuthorize() + @UseGuards(GqlFeatureGuard) + @RequireFeature("HR_MODULE"). Mutations carry @AuditMeta (audit-trail snapshots). Resolver extends ApBaseResolver<Timesheet>.
| Operation | Type | Input | Returns | Audit | Notes |
|---|---|---|---|---|---|
createTimesheet |
Mutation | CreateTimesheetInput |
Timesheet |
CREATE | Forces status: DRAFT, normalizes date to start-of-day, copies companyId from employee. |
updateTimesheet |
Mutation | id, UpdateTimesheetInput |
Timesheet |
UPDATE | Only regularHours/overtimeHours/description/approverId editable. Blocked unless DRAFT/REJECTED. |
deleteTimesheet |
Mutation | id |
Boolean |
DELETE | Soft-delete. Blocked unless DRAFT. |
timesheetPage |
Query | TimesheetPageInput |
TimesheetPageResult |
— | Paginated list. ESS self-scoped. |
submitTimesheets |
Mutation | TimesheetIdsInput { entryIds[] } |
Boolean |
STATUS_CHANGE | Bulk `DRAFT |
approveTimesheets |
Mutation | ApproveTimesheetInput { entryIds[], approvedById } |
Boolean |
STATUS_CHANGE | Bulk SUBMITTED → APPROVED; stamps approvedById + approvedAt. |
rejectTimesheets |
Mutation | RejectTimesheetInput { entryIds[], reason } |
Boolean |
STATUS_CHANGE | Bulk SUBMITTED → REJECTED; stamps rejectionReason. |
Note:
submit/approve/rejectare bulk (array of ids) and return a plainBoolean, not the updated rows. The admin refetches the page afterward.
Input DTOs (timesheet.dto.ts)
@InputType() class CreateTimesheetInput {
employeeId: string; date: number; regularHours: number;
overtimeHours?: number; description?: string; approverId?: string;
}
@InputType() class UpdateTimesheetInput { // NB: no employeeId/date — immutable after create
regularHours?: number; overtimeHours?: number; description?: string; approverId?: string;
}
@InputType() class TimesheetPageInput {
skip: Int; take: Int; employeeId?: ID; status?: TimesheetStatus; fromDate?: number; toDate?: number;
}
@InputType() class TimesheetIdsInput { entryIds: ID[]; }
@InputType() class ApproveTimesheetInput { entryIds: ID[]; approvedById: ID; }
@InputType() class RejectTimesheetInput { entryIds: ID[]; reason: string; }Pagination output: TimesheetPageResult { totalRecords: Float!, data: [Timesheet!]! }. There is no validation beyond @Field nullability — no class-validator decorators on these inputs; all business validation is service-level (§4).
REST
None. (No timesheet.controller.ts.)
Cross-module read (payroll & dashboard)
| Consumer | Reads via | What it computes |
|---|---|---|
payroll/employee/employee.service.ts → addOvertime() |
timesheetSvc.find({ employeeId, status: APPROVED, date: {$gte,$lte} }) |
totalOvertimeAmount = Σ overtimeHours × hourlyRate (§4.4). |
dashboard/hr-dashboard.service.ts → getTimesheetMetrics() |
timesheetRepo.aggregate(...) |
Completion % = distinct employees with any entry this month ÷ active employees. |
4. Business rules & calculations
4.1 Create rules (createTimesheet)
employeeSvc.findById(input.employeeId)— throwsBadRequestException("Employee not found")if missing.dateis normalized to start of day (setHours(0,0,0,0)).overtimeHoursdefaults to0if omitted;statusis forced toDRAFT.companyIdis taken from the employee record, not request context.- No uniqueness check — there is no guard against two entries for the same
(employeeId, date). Duplicate-day entries are allowed and both count toward payroll overtime. Flag (see §9).
4.2 Mutability guard (assertMutable)
private assertMutable(entry, action) {
if (entry.status === SUBMITTED || entry.status === APPROVED)
throw new BadRequestException(`Cannot ${action} a timesheet with status: ${entry.status}`);
}- Edit (
updateTimesheet) → blocked whenSUBMITTEDorAPPROVED(allowed forDRAFT/REJECTED). - Delete (
deleteTimesheet) → uses the same guard, so technically allowed forDRAFTandREJECTED. (The admin UI only shows the delete icon forDRAFT, soREJECTEDdeletion is reachable via API but not via the standard UI.)
4.3 Status state machine
submitTimesheets approveTimesheets
┌────────┐ (DRAFT|REJECTED → SUBMITTED) ┌───────────┐ (SUBMITTED → APPROVED) ┌──────────┐
│ DRAFT │ ───────────────────────────▶ │ SUBMITTED │ ──────────────────────▶ │ APPROVED │
└────────┘ └───────────┘ └──────────┘
▲ │
│ (edit allowed) │ rejectTimesheets
│ ▼ (SUBMITTED → REJECTED, +rejectionReason)
┌──────────┐ submitTimesheets ┌──────────┐
│ REJECTED │ ◀──────────────────────────┤ REJECTED │ (editable → resubmit)
└──────────┘ └──────────┘
Transition guards (service-level, enforced per batch — if any id in the batch is in the wrong state the whole call throws):
| Mutation | Allowed from | Sets | Guard error |
|---|---|---|---|
submitEntries |
DRAFT, REJECTED |
status=SUBMITTED |
"Only DRAFT or REJECTED entries can be submitted" |
approveEntries |
SUBMITTED |
status=APPROVED, approvedById, approvedAt=Date.now() |
"Only SUBMITTED entries can be approved" |
rejectEntries |
SUBMITTED |
status=REJECTED, rejectionReason |
"Only SUBMITTED entries can be rejected" |
APPROVED is terminal (no transition back). There is no unsubmit/recall path — a SUBMITTED entry can only move forward (approve) or back-to-editable (reject); the owner cannot pull it back themselves.
4.4 Payroll overtime formula (the one downstream calculation)
In payroll/employee/employee.service.ts → addOvertime():
const { workingHoursPerDay, workingDaysPerMonth } = await getWorkingSettings(employeeId);
const hourlyRate = salary / workingDaysPerMonth / workingHoursPerDay;
const timesheets = await timesheetSvc.find({ employeeId, status: APPROVED, date: {$gte: fromDate, $lte: toDate} });
const totalOvertimeHours = timesheets.reduce((sum, t) => sum + (t.overtimeHours || 0), 0);
model.totalOvertimeAmount = Math.round(totalOvertimeHours * hourlyRate * 100) / 100;Overtime pay =
Σ overtimeHours(APPROVED, in period) × (basicSalary ÷ workingDaysPerMonth ÷ workingHoursPerDay), rounded to 2 dp. OnlyAPPROVEDrows count.DRAFT/SUBMITTED/REJECTEDovertime is ignored. The hourly rate is the plain rate (no OT multiplier like 1.5× is applied — overtime is paid at the normal hourly rate). See payroll forgetWorkingSettings.
4.5 Dashboard completion metric (getTimesheetMetrics)
Period = current calendar month. completionPercentage = round(distinct employees with ≥1 entry this month ÷ active employees × 100); pendingCount = max(0, active − employeesWithTimesheet). Counts entries of any status (matches deleted: { $ne: true }, ignores status). Active = employees with no resignDate.
4.6 Side effects & transactionality
- No GL legs, no stock, no notifications. The only writes are to
hr_timesheetsitself (+ audit-trail snapshots via@AuditMeta). - Bulk status mutations use Mongo
updateMany— atomic per-collection but not wrapped in awithRetryTransaction. (Create/update/delete go through the base repo; there is no multi-document transaction needed because nothing else writes.) - No event is emitted on approve/reject (contrast: leave emits status events).
5. Permissions
- Feature gate:
@RequireFeature("HR_MODULE")+GqlFeatureGuard— company subscription must include HR. See subscription-config. - Auth:
@ApGqlAuthorize()(JWT). - RBAC (admin-enforced): the access constant
USER_ACCESS.HR_TIMESHEETS={ MODULE: 'hr-timesheets', ACTIONS: { view, create, update, delete, submit, approve } }(zerp-admin/src/constants/UserAccess.ts). The admin "Add Entry" button is gated oncreate. Note: the BE resolver itself does not apply per-action permission guards beyond the feature gate — action-level gating is enforced in the admin UI. Flag. - ESS self-scoping (repository):
buildQueryforcesemployeeId = contextSvc.employeeIdwhenever the caller is an employee, so an ESS user can only read their own timesheets viatimesheetPage. Admin/staff users (noemployeeIdon context) see the full company set. See permissions-access and ess.
6. Flows
6.1 Create → submit → approve (happy path)
Admin /hr/timesheet → "Add Entry" → CreateTimesheet (Formik modal)
→ createTimesheet({ employeeId, date(start-of-day), regularHours, overtimeHours, description, approverId? })
→ TimesheetResolver.create → TimesheetService.createTimesheet
├─ employeeSvc.findById → not found? throw "Employee not found"
├─ normalize date → start-of-day; overtimeHours ?? 0; status=DRAFT; companyId=employee.companyId
└─ timesheetRepo.create
→ audit CREATE ; ← Timesheet (DRAFT)
[select rows] → "Submit (n)" → submitTimesheets({ entryIds })
→ submitEntries: assert all DRAFT|REJECTED → repo.updateMany({status:SUBMITTED})
→ audit STATUS_CHANGE ; ← true ; admin refetch
[select rows] → "Approve" → approveTimesheets({ entryIds, approvedById: user._id })
→ approveEntries: assert all SUBMITTED → updateMany({status:APPROVED, approvedById, approvedAt:now})
→ audit STATUS_CHANGE ; ← true ; admin refetch
approvedByIdis the logged-in user's_id(admin page passesuser?._id), not theapproverIdchosen on the entry.
6.2 Reject → edit → resubmit
[select SUBMITTED rows] → "Reject" → reject modal → enter reason
→ rejectTimesheets({ entryIds, reason })
→ rejectEntries: assert all SUBMITTED → updateMany({status:REJECTED, rejectionReason})
← REJECTED (now editable again)
→ Edit icon (shown for REJECTED) → updateTimesheet(id, {regularHours,overtimeHours,description,approverId})
→ Submit again → SUBMITTED
6.3 Payroll consumption (read-only)
Payroll run → payroll/employee.service.addOvertime(employeeId, period)
→ timesheetSvc.find({ employeeId, status:APPROVED, date in period })
→ totalOvertimeAmount = Σ overtimeHours × hourlyRate → added to the employee's payslip
6.4 Unhappy paths
- Create with non-existent
employeeId→"Employee not found". - Edit/delete a
SUBMITTEDorAPPROVEDentry →"Cannot edit/delete a timesheet with status: <status>". - Submit a batch containing a non-
DRAFT/REJECTEDid → whole call throws"Only DRAFT or REJECTED entries can be submitted"(no partial apply). - Approve/reject a batch containing a non-
SUBMITTEDid → whole call throws the corresponding guard error. - The admin pre-filters
selectedIdsto the eligible status before calling, so these errors are mostly defensive.
7. Admin UI
| Area | Route | Module |
|---|---|---|
| Timesheets list + actions | /hr/timesheet |
src/modules/hr/timesheet |
Page (page.tsx)
- Table columns: Date (
fmtDate), Employee (resolved viaemployeeMapfromuseEmployeeState), Regular Hrs, Overtime Hrs, Total Hrs =(regularHours + overtimeHours).toFixed(1), Description, Status (colorTag: DRAFT=default, SUBMITTED=orange, APPROVED=green, REJECTED=red), Rejection Reason, Actions. - Row selection: checkbox column;
APPROVEDrows have the checkbox disabled (getCheckboxProps). Selecting rows reveals contextual bulk buttons:- Submit (n) — shown when any selected is
DRAFT/REJECTED; filters to eligible ids before calling. - Approve / Reject — shown when any selected is
SUBMITTED. Reject opens a modal asking for a reason. Approve passesuser._idasapprovedById.
- Submit (n) — shown when any selected is
- Filters: Status select (All + each enum value), From / To date pickers — all
ignoreFormik, each resetspage: 1. - Row actions: Edit icon only for
DRAFT/REJECTED; Delete (with confirm popover) only forDRAFT.
Create/edit form (components/create.tsx)
Reusable Formik modal (CreateTimesheet) for both create and edit.
- Yup schema:
employeeId(required object),date(required number),regularHours(required number),overtimeHours(optional),description(required — "Description of the workdone is required"),approverId(optional object). - Inputs: Employee select, Date picker (both disabled when editing — employee/date are immutable post-create), two
ApTimePickercontrols (timeMode="hours",HH:mm, 15-min step) for regular/overtime hours, optional Approver select, Description textarea. DefaultregularHours: 8,overtimeHours: 0. - Submit: create sends the full payload (date →
dayjs().startOf('day').valueOf()); update sends only{ regularHours, overtimeHours, description, approverId }(matchingUpdateTimesheetInput).
Context (context.tsx)
useTimesheetState() exposes: fetchTimesheetPage, createTimesheet, updateTimesheet, deleteTimesheet, submitTimesheets, approveTimesheets(ids, approvedById), rejectTimesheets(ids, reason), plus timesheets, loading, totalRecords, modal/setModal. After every mutation it toasts and refetch()es the current page (update/delete patch local state in place). GraphQL ops in gql/query.ts: TIMESHEET_PAGE (no-cache), CREATE_TIMESHEET, UPDATE_TIMESHEET, DELETE_TIMESHEET, SUBMIT_TIMESHEETS, APPROVE_TIMESHEETS, REJECT_TIMESHEETS.
The employee detail page also embeds a Timesheets tab (
EMPLOYEE_TIMESHEETS) — see employee §7.
8. Dependencies & integrations
Timesheet depends on / calls:
EmployeeModule(EmployeeService) — validatesemployeeIdand sourcescompanyIdon create. Hard dependency (forwardRef).AuthModule,SubscriptionModule(feature gate),TransactionManager(injected by the base service though no multi-doc tx is used).
Consumed by (read-only):
- payroll —
payroll/employeeimportsTimesheetModule(forwardRef) and readsAPPROVEDovertime hours viaTimesheetService.find→ overtime pay. - hr-dashboard — imports
TimesheetRepository, aggregateshr_timesheetsfor the completion metric (TimesheetMetrics) and per-department completion (PayrollMetrics.timesheetCompletionByDept, joininghr_timesheets).
Events: none emitted or consumed. Cron/jobs: none. External services: none.
Not integrated with: attendance (no clock-in derivation), shifts/timetables, projects/tasks, the workflow-approval-engine, and GL/accounting.
9. Gotchas & project-specific rules
- Approval bypasses the workflow engine. Timesheet uses a hand-rolled bulk status flip in its own repository — it does not go through the generic workflow-approval-engine that leave/claim/advance/loan use. There are no approval levels, no policy resolution, no orchestrator. The
approverIdfield is decorative. approverId≠approvedById.approverId(the intended approver,Employee._id) is captured but never enforced;approvedByIdis whoever (any user with the page) actually clicked Approve. The system does not verify the approver matches.- No per-(employee, date) uniqueness. Multiple entries for the same employee+day are allowed and all approved overtime sums into payroll — a data-entry duplicate inflates overtime pay. No dedupe guard. Flag.
- Overtime paid at the plain hourly rate.
addOvertimeusessalary / workingDaysPerMonth / workingHoursPerDaywith no OT multiplier (no 1.5×/2×). If statutory OT premiums are needed, that's an extension point. regularHoursis not paid. Payroll only readsovertimeHours. Regular hours exist purely for reporting and the dashboard completion %. Absence/base pay is computed independently in payroll from attendance + leave (addAbsenceDeductions), not from timesheet regular hours.companyIdcomes from the employee, not context. Unusual vs other modules — if an employee'scompanyIdis wrong, the timesheet inherits it.- ESS auto-scoping is silent. When
contextSvc.employeeIdis set,timesheetPageis force-filtered to that employee regardless of theemployeeIdarg supplied. An ESS user cannot query another employee's sheets even by passing an id. - Bulk mutations are all-or-nothing per batch. One bad-status id rejects the entire array; nothing in the batch is applied. The admin mitigates by pre-filtering selected ids to the eligible status.
- No BE action-level RBAC. Only the
HR_MODULEfeature gate is on the resolver; thesubmit/approveaction permissions exist only as admin UI gates. Anyone with HR feature + JWT can call the approve mutation server-side. dateis a start-of-day unix-ms number, normalized server-side on create but not on the page filter (fromDate/toDateare passed through as-is). The repository'sbuildQuerytreatsdateas a range key (dateKey: "date").