Skip to content

feat(ui): reports page, sortable lists and customer names (M7) - #6

Closed
gdarko wants to merge 4 commits into
feat/ui-timefrom
feat/ui-reports
Closed

gdarko wants to merge 4 commits into
feat/ui-timefrom
feat/ui-reports

Conversation

@gdarko

@gdarko gdarko commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Milestone 7: the reporting screen, plus the two list improvements the screens
around it were missing. Stacked on feat/ui-time.

Backend: the lists choose their order

GET projects takes sort_by in name, status, due_date, created_at,
default_rate; GET tasks takes sort_by in number, name, priority,
due_date, created_at. Both take sort_order in asc, desc, both
validated in the list request against the keys the service publishes, so an
unknown column is a 422 rather than a silently ignored parameter.

The sort runs in PHP over the collection the service already holds, not in the
query. Priority ranks LOW, NORMAL, HIGH, URGENT, which is not its alphabetical
order, and the three supported databases disagree about where a NULL lands, so
ordering here gives one answer everywhere without a CASE expression or a
NULLS LAST clause SQLite would refuse. It costs nothing extra: every list
endpoint already loads the whole collection and cuts the page from it in
Controller::paginate.

A row with no value sorts last in both directions, and ties break on the id in
the direction of the sort, so a page boundary never drops or repeats a row.

One behaviour change to review. The projects list now opens created_at desc rather than alphabetically, which is how the host's own lists open. Two
existing order assertions moved with it, and the two in-module project pickers
now ask for their list by name so they stay alphabetical.

Frontend

  • Sortable headers. Projects by name, status, rate and due date; tasks by
    number, name, priority and due date. BaseTable reports the column key it
    is sorting on and the empty order it starts in, so sortParams maps that to
    the endpoint's parameters and adds nothing until a header is clicked.
  • Contacts have names. A small store holds one map of contact id to
    display name per company session, filled the first time a screen renders a
    row that has one and dropped on company:changing. The projects table, the
    board card tooltip and the project detail header read it, which retires the
    detail page's single-contact fetch. Capped at 200: it labels ids the module
    already holds rather than browsing the address book, and forms keep their
    search-based picker.
  • Reports page at /admin/modules/tasks-projects/reports, reachable from
    the projects header next to Board. A range with presets, four figures per
    currency, a billable split drawn with two tokened divs rather than a chart
    library, and three breakdowns of the same range: by project, by member and
    by customer. It asks only for view-own-time, because the endpoint narrows
    the report to the caller's own time rather than refusing them.

The payload is read defensively throughout: a missing figure becomes zero and
a missing list an empty one, which renders as an empty table rather than as a
blank screen.

Checks

composer run lint, composer run test (236 green, up from 229),
pnpm run lint, pnpm exec tsc --noEmit, pnpm run build with
git diff --exit-code -- dist clean, and both module validators.
The bundle still has exactly one window.__invoiceshelf_vue destructuring and
no from "vue". Not yet exercised in a browser.

https://claude.ai/code/session_01DCf36XDKprZifej8dc2r1E

Both list endpoints take `sort_by` and `sort_order`, validated against the
keys the service publishes so an unknown column is a 422 rather than a
silently ignored parameter. Projects order by name, status, due date,
created date or rate; tasks by number, name, priority, due date or created
date.

The sort runs in PHP over the collection the service already holds, not in
the query. Priority ranks LOW, NORMAL, HIGH, URGENT, which is not its
alphabetical order, and the three supported databases disagree about where a
NULL lands, so ordering here gives one answer everywhere without a CASE
expression or a `NULLS LAST` clause SQLite would refuse. It costs nothing
extra: every list endpoint already loads the whole collection and cuts the
page from it.

A row with no value sorts last in both directions, and ties break on the id
in the direction of the sort, so a page boundary never drops or repeats a
row. Naming a column reads it ascending unless the caller says otherwise.

The projects list now opens newest first rather than alphabetically, which is
how the host's own lists open; two existing order assertions move with it.
A range, four figures per currency, a billable split and three breakdowns of
the same range: by project, by member and by customer. Everything comes from
one call to `reports/summary`, which already aggregates per currency and
never converts between them, so the page never adds two currencies together
either.

The range offers this week, this month, last month, this quarter and this
year, and switches to Custom the moment a date is picked by hand. "This week"
follows the company's week-start setting, so the report and the timesheet
agree about where a week begins.

The page asks only for `view-own-time`. The endpoint narrows the report to
the caller's own time rather than refusing them, so gating on `view-all-time`
would hide a screen that works.

The split bar charts minutes rather than money, because minutes are the one
figure that adds up across currencies, and it is drawn with two tokened divs
rather than a chart library the bundle would have to carry.

The payload is read defensively throughout: a missing figure becomes zero and
a missing list an empty one, which renders as an empty table rather than as a
blank screen.
The project columns name, status, rate and due date, and the task columns
number, name, priority and due date, are now sortable headers. `BaseTable`
reports the column key it is sorting on and the empty order it starts in, so
`sortParams` maps that to the `sort_by` and `sort_order` the endpoints take
and adds nothing at all until a header is clicked.

Contacts stop reading as `#42`. A small store holds one map of contact id to
display name per company session, filled the first time a screen renders a
row that has one and dropped on `company:changing`. The projects table, the
board card tooltip and the project detail header all read it, which also
retires the detail page's single-contact fetch.

The map is capped at 200 contacts on purpose: it exists to label ids the
module already holds, not to browse the address book, and forms keep their
search-based picker for choosing one. Anything past the cap falls back to
`#id`, as does a contact the caller may not read.

The two project pickers ask for their list by name, because a picker reads
alphabetically whatever order the endpoint itself opens in.
Assets ship compiled, so the package installs without a build step on the
target system.
@gdarko

gdarko commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #14, which carries this stack consolidated into three commits on top of main.

@gdarko gdarko closed this Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant