Timesheet — daily hours logging with submit → approve/reject

The whole timesheet model reduces to one idea: a Timesheet is one (employee, day) row recording regularHours + 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 sums overtimeHours of APPROVED rows 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 regularHours and overtimeHours worked plus a free-text description of 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/REJECTED entries can be edited/submitted; only DRAFT can be deleted; SUBMITTED/APPROVED are 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 projectId or task field. "Project time tracking" is not implemented — the only context is the free-text description. 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. The approverId field is informational only — it is never read to route or gate the approval. Flag (see §9).
  • Compute regular-hours pay. Payroll reads only overtimeHours; regularHours is 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: companyId is copied from the employee at create time (createTimesheet sets companyId: employee.companyId), not from request context. The repository's buildQuery adds an ESS self-scoping rule: if the caller is an employee (contextSvc.employeeId set), the query is force-filtered to employeeId: 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/reject are bulk (array of ids) and return a plain Boolean, 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)

  1. employeeSvc.findById(input.employeeId) — throws BadRequestException("Employee not found") if missing.
  2. date is normalized to start of day (setHours(0,0,0,0)).
  3. overtimeHours defaults to 0 if omitted; status is forced to DRAFT.
  4. companyId is taken from the employee record, not request context.
  5. 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 when SUBMITTED or APPROVED (allowed for DRAFT/REJECTED).
  • Delete (deleteTimesheet) → uses the same guard, so technically allowed for DRAFT and REJECTED. (The admin UI only shows the delete icon for DRAFT, so REJECTED deletion 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. Only APPROVED rows count. DRAFT/SUBMITTED/REJECTED overtime 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 for getWorkingSettings.

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_timesheets itself (+ audit-trail snapshots via @AuditMeta).
  • Bulk status mutations use Mongo updateMany — atomic per-collection but not wrapped in a withRetryTransaction. (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 on create. 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): buildQuery forces employeeId = contextSvc.employeeId whenever the caller is an employee, so an ESS user can only read their own timesheets via timesheetPage. Admin/staff users (no employeeId on 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

approvedById is the logged-in user's _id (admin page passes user?._id), not the approverId chosen 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 SUBMITTED or APPROVED entry → "Cannot edit/delete a timesheet with status: <status>".
  • Submit a batch containing a non-DRAFT/REJECTED id → whole call throws "Only DRAFT or REJECTED entries can be submitted" (no partial apply).
  • Approve/reject a batch containing a non-SUBMITTED id → whole call throws the corresponding guard error.
  • The admin pre-filters selectedIds to 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 via employeeMap from useEmployeeState), Regular Hrs, Overtime Hrs, Total Hrs = (regularHours + overtimeHours).toFixed(1), Description, Status (color Tag: DRAFT=default, SUBMITTED=orange, APPROVED=green, REJECTED=red), Rejection Reason, Actions.
  • Row selection: checkbox column; APPROVED rows 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 passes user._id as approvedById.
  • Filters: Status select (All + each enum value), From / To date pickers — all ignoreFormik, each resets page: 1.
  • Row actions: Edit icon only for DRAFT/REJECTED; Delete (with confirm popover) only for DRAFT.

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 ApTimePicker controls (timeMode="hours", HH:mm, 15-min step) for regular/overtime hours, optional Approver select, Description textarea. Default regularHours: 8, overtimeHours: 0.
  • Submit: create sends the full payload (date → dayjs().startOf('day').valueOf()); update sends only { regularHours, overtimeHours, description, approverId } (matching UpdateTimesheetInput).

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) — validates employeeId and sources companyId on 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):

  • payrollpayroll/employee imports TimesheetModule (forwardRef) and reads APPROVED overtime hours via TimesheetService.find → overtime pay.
  • hr-dashboard — imports TimesheetRepository, aggregates hr_timesheets for the completion metric (TimesheetMetrics) and per-department completion (PayrollMetrics.timesheetCompletionByDept, joining hr_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

  1. 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 approverId field is decorative.
  2. approverIdapprovedById. approverId (the intended approver, Employee._id) is captured but never enforced; approvedById is whoever (any user with the page) actually clicked Approve. The system does not verify the approver matches.
  3. 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.
  4. Overtime paid at the plain hourly rate. addOvertime uses salary / workingDaysPerMonth / workingHoursPerDay with no OT multiplier (no 1.5×/2×). If statutory OT premiums are needed, that's an extension point.
  5. regularHours is not paid. Payroll only reads overtimeHours. 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.
  6. companyId comes from the employee, not context. Unusual vs other modules — if an employee's companyId is wrong, the timesheet inherits it.
  7. ESS auto-scoping is silent. When contextSvc.employeeId is set, timesheetPage is force-filtered to that employee regardless of the employeeId arg supplied. An ESS user cannot query another employee's sheets even by passing an id.
  8. 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.
  9. No BE action-level RBAC. Only the HR_MODULE feature gate is on the resolver; the submit/approve action permissions exist only as admin UI gates. Anyone with HR feature + JWT can call the approve mutation server-side.
  10. date is a start-of-day unix-ms number, normalized server-side on create but not on the page filter (fromDate/toDate are passed through as-is). The repository's buildQuery treats date as a range key (dateKey: "date").