Status lifecycle
How a timesheet day moves from draft to submitted, approved, exported and locked; who may make each transition; and how client approval modes and corrections interact.
Last updated: · Markdown version
Every day (one placement on one date) has a status. The same state machine runs in the web app, the mobile app, the API and database triggers.
employee/office office · client · admin system (export commit) admin/system (lock)
draft ───────────────────► submitted ───────────────────► approved ─────────────────────► exported ───────────────► locked
▲ │ ▲ │
└─── employee/office ───────┘ └──── office/admin ──────────┘
(office needs a reason) (reason required)
Transitions
| From → To | Who | API | Notes |
|---|---|---|---|
draft → submitted |
employee, office, admin | POST /weeks/submit {weekStart, personId} |
Submits every draft day of the ISO week that has minutes or a conflict. weekStart is normalised to the Monday. Returns {submitted: n}. Emits timesheet.submitted. |
submitted → draft |
employee, office (with a reason), admin | dashboard (reject) | Sends hours back so the employee can edit them. |
submitted → approved |
office, client, admin | POST /days/approve {dayIds} or the client link |
Only conflict-free submitted days. See approval modes below. Emits timesheet.approved (office) and timesheet.client_approved (client). |
approved → submitted |
office, admin (reason required) | dashboard; also automatic when the value changes | — |
approved → exported |
system | POST /exports {"mode":"commit"} |
Days get export_run_id. Emits export.completed. |
exported → locked |
admin, system | POST /periods/lock (admin key or admin user) |
Emits period.locked. |
A disallowed transition returns 409 with code invalid_transition, forbidden or reason_required.
Client approval modes
Each client has an approval_mode:
| Mode | Office approves | Client approves (magic link) | Day becomes approved when |
|---|---|---|---|
office |
required | optional | the office approves |
client |
optional | required | the client approves (office approval only records approved_by_office_at) |
both |
required | required | both have approved, in either order |
POST /days/approve reports the split. This response was captured on a placement whose client has approval_mode: client:
{ "data": { "approved": 0, "waitingForClient": 1, "skipped": 0 } }
skipped counts days that were not submitted or that have a conflict.
Immutability: exported and locked days never change
exported and locked are final. Any write that would change such a day is refused:
{ "error": { "code": "locked", "message": "This day is exported or locked; book a correction in an open period" } }
That response has HTTP status 423. Corrections for an exported day are recorded as new hours in an open period. Example: an extra manual_office claim on a day in the current week, with a note that refers to the original date. The payroll system then processes the difference. See periods and locking.
The check runs before anything is stored, so a refused claim leaves no trace in GET /claims.
Changes invalidate approvals
When a new claim changes a day's value (minutes, start or end) or creates a conflict:
- an
approvedday goes back tosubmitted, and - both office and client approvals are cleared, so they must be given again for the new value.
This keeps the client's signature tied to the hours they actually saw.
Employees vs office
- Employees can change only
draftdays of their own placements, using sourcesapporkiosk. After submitting they get 409: "These hours are already submitted; ask the office to change them". - Office credentials (including write-scope API keys) can add claims at any status except
exported/locked.
Reading statuses
- Week view:
GET /org/weekgives astatusper day cell and per row (draft,submitted,approved,exported,locked,mixed, oremptywhen nothing was recorded). - Any range:
GET /days?from=…&to=…&status=submitted,approved. See pagination. - Events:
timesheet.submitted,timesheet.approved,timesheet.client_approved,export.completed,period.locked. See events.