feat(timesheets): status filter + multi-week range + decision audit on list (PMS-506) #360

Merged
YousifShkara merged 2 commits from feat/PMS-506-timesheet-history into main 2026-06-25 09:08:57 +02:00
Owner

The week-rolled GET /timesheets endpoint accepted only user_id +
week filters and dropped the decision audit (approved_by_id /
approved_at / rejection_reason) on its way through the
TimesheetRow -> TimesheetSummaryResponse conversion. The SPA's
admin approvals queue then filtered the response down to
approval_status == "pending", so approved + rejected weeks
"disappeared" after the admin clicked Approve - matches the QA
report.

Server contract changes:

  • TimesheetFilter gains status: Option<String> (pending /
    approved / rejected / all), from: Option<NaiveDate>, and
    to: Option<NaiveDate>. status validates server-side (422 on an
    unknown value); from/to overrides the legacy single-week field.
  • list_timesheets resolves from/to against the Monday anchor,
    caps the span at 26 weeks (422 if longer), widens the date WHERE
    to scan the range, and HAVING-filters the rolled CASE expression
    on the requested status. The aggregate carries decided_at
    (MAX(approved_at)), decided_by_id, and rejection_reason - all
    identical across the rolled rows because approve_week / reject_week
    run a single UPDATE per week - via ARRAY_AGG ... FILTER.
  • TimesheetSummaryResponse exposes decided_by_id / decided_at /
    rejection_reason as Options; they serialize as absent on pending
    weeks.

Integration tests (tests/timesheet_history.rs):

  • list_timesheets_status_filter_and_range seeds three weeks (one
    approved, one rejected with a reason, one pending), then asserts
    each status filter returns exactly the right subset with the right
    audit fields populated.
  • list_timesheets_rejects_unknown_status pins the 422 from the
    enum guard.
  • list_timesheets_caps_range_at_26_weeks pins the 26-week scan cap.

Client side (status filter + range mode + history rendering on
TimesheetApprovalsPage) lands in the mokosh-apps PR under the same
ticket id.

#PMS-506

The week-rolled `GET /timesheets` endpoint accepted only `user_id` + `week` filters and dropped the decision audit (approved_by_id / approved_at / rejection_reason) on its way through the `TimesheetRow -> TimesheetSummaryResponse` conversion. The SPA's admin approvals queue then filtered the response down to `approval_status == "pending"`, so approved + rejected weeks "disappeared" after the admin clicked Approve - matches the QA report. Server contract changes: - TimesheetFilter gains `status: Option<String>` (`pending` / `approved` / `rejected` / `all`), `from: Option<NaiveDate>`, and `to: Option<NaiveDate>`. status validates server-side (422 on an unknown value); from/to overrides the legacy single-`week` field. - list_timesheets resolves `from`/`to` against the Monday anchor, caps the span at 26 weeks (422 if longer), widens the date WHERE to scan the range, and HAVING-filters the rolled CASE expression on the requested status. The aggregate carries decided_at (`MAX(approved_at)`), decided_by_id, and rejection_reason - all identical across the rolled rows because approve_week / reject_week run a single UPDATE per week - via `ARRAY_AGG ... FILTER`. - TimesheetSummaryResponse exposes decided_by_id / decided_at / rejection_reason as `Option`s; they serialize as absent on pending weeks. Integration tests (tests/timesheet_history.rs): - list_timesheets_status_filter_and_range seeds three weeks (one approved, one rejected with a reason, one pending), then asserts each status filter returns exactly the right subset with the right audit fields populated. - list_timesheets_rejects_unknown_status pins the 422 from the enum guard. - list_timesheets_caps_range_at_26_weeks pins the 26-week scan cap. Client side (status filter + range mode + history rendering on `TimesheetApprovalsPage`) lands in the mokosh-apps PR under the same ticket id. #PMS-506
feat(timesheets): status filter + multi-week range + decision audit on list (PMS-506)
Some checks failed
E2E / Playwright against staging (pull_request) Successful in 59s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m39s
Integration / integration tests (pull_request) Failing after 12m22s
60b7fe51bf
The week-rolled `GET /timesheets` endpoint accepted only `user_id` +
`week` filters and dropped the decision audit (approved_by_id /
approved_at / rejection_reason) on its way through the
`TimesheetRow -> TimesheetSummaryResponse` conversion. The SPA's
admin approvals queue then filtered the response down to
`approval_status == "pending"`, so approved + rejected weeks
"disappeared" after the admin clicked Approve - matches the QA
report.

Server contract changes:
- TimesheetFilter gains `status: Option<String>` (`pending` /
  `approved` / `rejected` / `all`), `from: Option<NaiveDate>`, and
  `to: Option<NaiveDate>`. status validates server-side (422 on an
  unknown value); from/to overrides the legacy single-`week` field.
- list_timesheets resolves `from`/`to` against the Monday anchor,
  caps the span at 26 weeks (422 if longer), widens the date WHERE
  to scan the range, and HAVING-filters the rolled CASE expression
  on the requested status. The aggregate carries decided_at
  (`MAX(approved_at)`), decided_by_id, and rejection_reason - all
  identical across the rolled rows because approve_week / reject_week
  run a single UPDATE per week - via `ARRAY_AGG ... FILTER`.
- TimesheetSummaryResponse exposes decided_by_id / decided_at /
  rejection_reason as `Option`s; they serialize as absent on pending
  weeks.

Integration tests (tests/timesheet_history.rs):
- list_timesheets_status_filter_and_range seeds three weeks (one
  approved, one rejected with a reason, one pending), then asserts
  each status filter returns exactly the right subset with the right
  audit fields populated.
- list_timesheets_rejects_unknown_status pins the 422 from the
  enum guard.
- list_timesheets_caps_range_at_26_weeks pins the 26-week scan cap.

Client side (status filter + range mode + history rendering on
`TimesheetApprovalsPage`) lands in the mokosh-apps PR under the same
ticket id.

#PMS-506
fix(test): timesheet_history fixture must spell out pagination (PMS-506)
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 3m21s
E2E / Playwright against staging (pull_request) Successful in 59s
Integration / integration tests (pull_request) Successful in 11m40s
Create release / Create release from merged PR (pull_request) Successful in 2s
3227ab4ab8
CI report: `list_timesheets_status_filter_and_range` failed with
`got 1 row; want 3`. The seed wrote three weekly entries (approved A,
rejected B, pending C) over a range, but the service returned only
the newest one.

Root cause is the test fixture, not the production query:
`PaginationParams::default()` derives Rust's `Default` which zeroes
`per_page` (the serde `default_per_page = 25` fires only when
deserialised from a query string). The service then clamps
`per_page.clamp(1, MAX_PER_PAGE)` to 1, so the SQL ran with
`LIMIT 1 OFFSET 0` and only the first row in the
`ORDER BY week_start DESC` cursor came back - week C, the newest.

Spell out `PaginationParams { page: 1, per_page: 25, ... }` in the
local `page()` helper so the multi-row assertions actually exercise
the multi-week range. Service code untouched - the bug never hit a
real HTTP caller because the query-string defaults wire up correctly
on the route layer; this was a tests-only blind spot.

#PMS-506
YousifShkara deleted branch feat/PMS-506-timesheet-history 2026-06-25 09:08:57 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!360
No description provided.