# 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.

URL: https://timetracking.tech/developers/concepts/status-lifecycle
Last updated: 2026-10-24

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`:

```json
{ "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:

```json
{ "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](https://timetracking.tech/developers/concepts/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 `approved` day goes back to `submitted`, 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 `draft` days of their own placements, using sources `app` or `kiosk`. 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/week` gives a `status` per day cell and per row (`draft`, `submitted`, `approved`, `exported`, `locked`, `mixed`, or `empty` when nothing was recorded).
- Any range: `GET /days?from=…&to=…&status=submitted,approved`. See [pagination](https://timetracking.tech/developers/api/pagination).
- Events: `timesheet.submitted`, `timesheet.approved`, `timesheet.client_approved`, `export.completed`, `period.locked`. See [events](https://timetracking.tech/developers/webhooks/events).
