- Users submit a username and password at
/login. - The backend normalises the username to lowercase before lookup.
- The password is verified against the bcrypt hash (cost 12).
- If the user's status is not
active, login is rejected. - On success the API returns a JWT access token (15 min expiry) and sets an HTTP-only refresh cookie (7 days).
- The frontend stores the access token in JavaScript memory (not localStorage) and attaches it as a
Bearerheader on every API call.
- On page load, the frontend calls
POST /api/auth/refreshto restore a session from the cookie. - When an API call returns 401, the Axios response interceptor automatically attempts a refresh before retrying the failed request.
- Concurrent 401 responses share a single refresh promise to avoid race conditions.
POST /api/auth/logoutclears the refresh cookie.- The frontend clears the in-memory access token and redirects to
/login.
All admin pages follow the same pattern: a data table with sortable columns, an "Add New" button, and an "Edit" action per row. Create and edit forms open in a modal dialog.
| Column | Description |
|---|---|
| Name | Unique client name |
| Status | active or inactive |
Form fields: name (text), status (select).
Validation: name is required and must be unique (409 on duplicate).
| Column | Description |
|---|---|
| Client | Parent client name |
| Code | Unique engagement code |
| Description | Short description |
| Type | Fixed Price or T&M |
| Budget | Total engagement value in EUR |
| Start Date / End Date | Engagement period |
Form fields: client (searchable select of active clients), code, description, type, budget, start date, end date.
Validation: start date must be before end date. Code must be unique.
| Column | Description |
|---|---|
| Username | Always lowercase |
| Role | admin or employee |
| Status | active or inactive |
Form fields: username, password (only on create), role, status.
Separate action: "Password" button opens a modal to change a user's password.
Backend rules: username is normalised to lowercase and trimmed. Password is hashed with bcrypt cost 12. Password is never returned in any API response.
| Column | Description |
|---|---|
| Username | Employee name |
| Hourly Rate | Internal cost rate in EUR |
| Start Date | When this rate becomes effective |
| End Date | When this rate expires (- = indefinite) |
Form fields: user (searchable select), hourly rate, start date, end date (optional).
Filter: dropdown to filter the table by user.
| Column | Description |
|---|---|
| Username | Assigned employee |
| Engagement | Code + client name |
| Billing Rate | Per-hour billing rate (T&M only) |
| Start Date / End Date | Assignment period |
Form fields: user (searchable select), engagement (searchable select showing code + client + type), billing rate (shown only for T&M engagements), start date, end date.
Auto-fill: selecting an engagement auto-fills start/end dates from the engagement's date range.
Backend validation rules:
- Assignment dates must fall within the engagement's date range.
billing_rateis required if and only if the engagement type ist&m.- For the same
(user, engagement)pair, date ranges must not overlap.
A left/right arrow calendar picker defaults to the current month.
Displays all entries for the authenticated user in the selected month:
| Column | Description |
|---|---|
| Date | Entry date (DD/MM/YYYY) |
| Assignment | Engagement code + client name |
| Actual Hours | Hours worked |
| Billed Hours | Hours billed (T&M only, - for fixed-price) |
| Description | Free text |
| Actions | Edit / Delete (only in draft) |
Entries are sorted by date ascending.
The form includes:
- Date: date picker constrained to the selected month.
- Assignment: select from the user's active assignments whose date range covers the selected entry date.
- Actual Hours: number input with step 0.25.
- Billed Hours: number input with step 0.25 (shown only for T&M engagements).
- Description: text area.
When the user creates the first entry for a month that has no timesheet, a draft timesheet is automatically created.
| Rule | Scope | Threshold | Behaviour |
|---|---|---|---|
| Max actual hours | Per user, per date | >= 24 | Blocking — entry is rejected |
| High actual hours | Per user, per date | > 8 | Warning — yellow toast, entry is saved |
| Max billed hours | Per client, per date (T&M only) | >= 24 | Blocking — entry is rejected |
| High billed hours | Per client, per date (T&M only) | > 8 | Warning — yellow toast, entry is saved |
When the timesheet status is draft and at least one entry exists, a "Submit Timesheet" button is shown. On click, status changes to submitted and all entries become read-only for the employee.
The current timesheet status is shown as a colour-coded badge:
- Draft — yellow
- Submitted — blue
- Approved — green
- User: searchable select to pick any user.
- Month: same calendar month picker.
Same table as the employee view, showing entries for the selected user and month.
Admins can add, edit, and delete entries on submitted timesheets. The same validation rules apply (hour thresholds, assignment ownership, billed hours for T&M).
If the admin creates the first entry for a month with no timesheet, a draft timesheet is auto-created.
| Current status | Available actions |
|---|---|
draft |
(no admin actions — employee must submit first) |
submitted |
Send Back to Draft, Approve |
approved |
(read-only for everyone) |
All three reports are admin-only and follow the same pattern: select a user and month, optionally drill into a specific engagement or client, view a data table, and export to Excel.
Shows all time entries for a selected user in a selected month.
| Column | Description |
|---|---|
| Date | Entry date |
| Actual Hours | Hours worked |
| Client Name | Client of the engagement |
| Engagement Code | Engagement code |
| Description | Entry description |
Export: downloads an .xlsx file with the same columns.
- Select a user and month.
- A dropdown appears listing T&M engagements the user logged time against in that month.
- Select an engagement to see entries.
| Column | Description |
|---|---|
| Date | Entry date |
| Billed Hours | Hours billed |
| Description | Entry description |
Export: .xlsx with the same columns.
- Select a user and month.
- A dropdown appears listing clients that have T&M engagements with logged time.
- Select a client to see entries.
| Column | Description |
|---|---|
| Date | Entry date |
| Billed Hours | Hours billed |
| Engagement Code | Engagement code |
| Description | Entry description |
Export: .xlsx with the same columns.
All exports use the exceljs library. The backend constructs a workbook in memory, writes it to the HTTP response stream with the appropriate Content-Type and Content-Disposition headers, and the browser triggers a download. The frontend fetches the export URL with the Bearer token via a fetch + Blob pattern (since Axios does not handle binary downloads as cleanly).
Fixed-price engagements:
revenue = engagement.budget
cost = SUM(time_entry.actual_hours * cost_rate.hourly_rate)
margin = revenue - cost
T&M engagements:
revenue = SUM(time_entry.billed_hours * assignment.billing_rate)
cost = SUM(time_entry.actual_hours * cost_rate.hourly_rate)
margin = revenue - cost
Margin per worked hour:
margin_per_hour = margin / SUM(actual_hours) // 0 if no hours logged
The cost rate lookup finds the cost_rates row where user_id matches the time entry's user, start_date <= entry.date, and either end_date >= entry.date or end_date IS NULL.
| Column | Sortable | Description |
|---|---|---|
| Client Name | No | |
| Engagement Code | No | |
| Engagement Type | No | Fixed Price or T&M badge |
| Revenue | No | Formatted with 2 decimals + EUR symbol |
| Cost | No | Formatted with 2 decimals + EUR symbol |
| Margin | Yes | Click header to sort. Green if > 0, red if < 0 |
| Margin/Hour | Yes | Click header to sort. Same colour coding |