Skip to content

feat(ui): invoice tasks and projects from where the work is (rework PR 3, frontend) - #11

Closed
gdarko wants to merge 10 commits into
feat/task-centric-invoicing-apifrom
feat/task-centric-invoicing-ui
Closed

gdarko wants to merge 10 commits into
feat/task-centric-invoicing-apifrom
feat/task-centric-invoicing-ui

Conversation

@gdarko

@gdarko gdarko commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

The frontend half of PR 3. The backend it talks to (billing/prepare taking
task ids and a project, InvoiceLineComposer, the line-note settings) is on
the base branch.

What changes

Invoicing stops being a place you go to and becomes something you do where the
work is.

  • support/invoicing.ts is the whole of it: billing/prepare for the
    selection, then the host's own defaults (due date, template, number, and an
    exchange rate when the contact does not settle in the company currency), then
    POST /api/v1/invoices with the session's client, then billing/confirm,
    then /admin/invoices/{id}/edit, falling back to /view when the host's
    edit-invoice guard refuses the push. The module still never writes to the
    host invoice tables.
  • The actions are wired everywhere the UI PR left a placeholder: the task
    row menu, the bulk bar (the selection goes as one invoice, so two customers
    are one clear message rather than two invoices), the task page header, the
    project overview card and the project page header.
  • The wizard is gone. pages/UnbilledTimePage.vue takes its billing id,
    path and ability and answers the one question the task screens cannot: who
    owes money for what across every project, and which single entry to leave
    off. Linked from the Reports and Projects headers, and from nowhere in the
    sidebar.

The three things that can go wrong

  • Two customers in one selection. 422 mixed_billing_selection becomes
    "Select tasks of one customer", naming how many it spanned. The same refusal
    also covers two currencies, which carries no ids, so the counted sentence is
    used only when it is true.
  • Nothing left to bill. 422 nothing_to_invoice becomes a plain notice,
    not an error against a field the user filled in correctly.
  • A created invoice whose entries were not stamped. This is the only
    failure that leaves work behind: the invoice exists and the hours still read
    as unbilled. It is parked in stores/invoicing.ts, InvoiceRetryBanner
    offers the idempotent confirm again on the Tasks screen, the task page, the
    project page and the unbilled time page, and the module refuses to start
    another invoice until it lands, so a second click can never produce a
    duplicate.

Notes

  • The invoice number comes from GET /api/v1/next-number unless the
    company has invoice_auto_generate = NO, or the endpoint could not answer,
    in which case InvoiceNumberModal asks. That dialog is mounted once as a
    company layout overlay rather than by each screen that can invoice; backing
    out of it cancels the invoice rather than creating one with a blank number.
  • The ability is inferred from the first 403 on prepare, since the module
    settings endpoint does not report it. After one refusal the actions stop
    offering themselves for the session.
  • Amounts on the unbilled time page render in the company's format, as they
    already do on the task list and the project cards, rather than fetching a
    per-contact currency to label them with. The customer and currency split
    stays in the data, because prepare refuses a selection that mixes either.
  • The banner carries a "Forget this invoice" escape for an invoice deleted in
    the host, which would otherwise lock the module out of invoicing for the
    session. It confirms, and says the time stays unbilled.

Verified

pnpm run lint, pnpm exec tsc --noEmit, pnpm run build (one
window.__invoiceshelf_vue, no from "vue", dist/ reproducible),
composer run lint, composer run test 312 green and unchanged. No browser
pass from this worktree; the orchestrator runs the live invoicing pass after
the merge.

https://claude.ai/code/session_01DCf36XDKprZifej8dc2r1E

`support/invoicing.ts` is the whole of invoicing: prepare the selection, fill
in the host's own defaults, post the invoice with the session's client, stamp
the entries with the line ids that come back, and land on the draft. Every
entry point runs it and nothing else, so "invoice this" means the same four
steps from a task row, a bulk bar, a task page, a project header or the
unbilled time page.

`prepareInvoice` now takes the selection rather than a list of entry ids, so a
caller names whichever of the three shapes its screen knows. The contact is
read for the currency it settles in, because the host compares that with the
company setting when it decides whether an exchange rate is required.

Three facts outlive the screen that caused them, so they live in
`stores/invoicing.ts`:

- one module-wide lock, because the sequence is four requests long and a
  second click elsewhere would race the same hours onto two invoices;
- an invoice the host created whose entries were not stamped, which is the one
  failure that leaves work behind: the time still reads as unbilled, so
  `InvoiceRetryBanner` offers the idempotent confirm again and the module
  refuses to start another invoice until it lands;
- whether the caller may invoice at all, learned from the first 403, since the
  settings endpoint does not say.

`InvoiceNumberModal` is mounted once rather than by each screen that can
invoice: a company that numbers its invoices by hand is the only one ever
asked, and backing out of that question cancels the invoice rather than
creating one with a blank number.
The placeholders the UI slice left behind now do the thing they promised. A
task row and the task page invoice one task, the bulk bar invoices the
selection as one invoice, and the project overview card and the project header
invoice everything unbilled on a project.

Each of them is a call to the same sequence, so none of them has an opinion of
its own: the selection travels as task ids or a project id and the server
decides what is still billable. Sending the bulk selection together rather
than looping is the point of it, because that is what turns two customers into
one clear message instead of two invoices.

The actions show themselves only when they would mean something. A task with
nothing billable left, or one already on an invoice, keeps the entry in the
menu but refuses it with a title saying which of the two it is, so the menu
never changes shape as work is logged. Everything hides once the server has
refused the ability once.

The retry banner rides on the Tasks screen, the task page and the project page,
which is where someone will be standing when a stamp fails, and the task list
and board refresh themselves through the shared task version.
The wizard asked its question backwards. Picking a customer and then their
entries is four steps to reach something the task screens now do in one click,
so it is gone, and what is left is the one question those screens cannot
answer: who owes money for what, across every project, and which single entry
should be left off.

`UnbilledTimePage` keeps the `billing` id, path and ability the wizard had, so
a bookmark and the registered ability both still resolve. It opens on the
customers with time waiting, drops into their entries with the grouping picker
and the checkboxes, and hands the ticked ids to the same sequence every other
screen runs. The date window survives from the wizard, because month-end
billing is the reason to be on this page at all.

It is linked from the Reports and Projects headers and from nowhere in the
sidebar: it is a report with a button, not a place to live.

Amounts render in the company's format, as they already do on the task list
and the project cards, rather than carrying a per-contact currency the page
would have to fetch to label correctly. The customer and currency split stays
in the data, because `prepare` refuses a selection that mixes either.
One `window.__invoiceshelf_vue` and no import from "vue", so the bundle still
runs on the host's own Vue instance.
The task list carried eleven columns at the host table's `px-6`, which made
it some three hundred pixels wider than the content area on a 1280 screen:
the row menu sat past the right edge, behind a horizontal scrollbar nobody
goes looking for, so editing or deleting a task from the list was
unreachable at the width most people run the app at.

The due date moves under the task name, where it is a detail of the task
rather than a column that is empty on most rows, and the remaining cells
use half the host's horizontal padding. The table now fits the content area
at 1280 and 1440, and the row menu is where the eye expects it.
The three "Invoice task" and "Invoice project" placeholders are disabled
until the invoicing slice lands, but the host button renders a disabled
button exactly like a live one: same border, same colour, full opacity. A
person reading the task page, the bulk bar or a project's Unbilled card saw
an ordinary action and got nothing when they clicked it.

The wrapper that already carries the "coming with invoicing" tooltip now
dims the button and shows the not-allowed cursor, so the affordance reads
as the promise it is.
The task form's Customer field printed the raw contact id, so editing a
task showed "#22" where every other screen in the module shows "Acme Corp".
The company's contact map already exists for exactly this, and the form is
now the screen that asks for it when a task carries a customer: opening the
form from the list, where nothing had loaded the map yet, fills the field
in rather than leaving a number behind.
At `w-72` the board needs 1200 pixels for the four statuses a company starts
with, which is eighty more than the content area offers on a 1440 screen:
the Done column arrived sliced down the middle, its badge cut in half, which
reads as a broken screen rather than as a board to scroll.

Columns are `w-64` now, so the default board fits the page and a company
that adds statuses still gets the sideways scroll the layout was built for.
…voicing-ui

The verification pass had dimmed the disabled Invoice placeholders; the
real actions from the invoicing UI replace them, so those hunks keep the
invoicing side.
@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