feat(quotes): staff and portal Quotes UI (PMS-675) #436

Merged
longjacksonle merged 5 commits from feat/PMS-675-quotes-ui into main 2026-07-21 20:43:50 +02:00

What

PMS-675, the final phase of the PMS-670 Quotes epic and the only one in this repo. Adds the Quotes UI: staff list / detail / editor, and the client-facing portal view where a quote is accepted or declined.

Server side (PMS-671 through PMS-674) is already merged in mokosh-server.

Staff

  • List at /quotes, filtered by status and company, with a status column.
  • Detail showing scope, line items, totals, the approvals section, and the actions valid in the current state.
  • Editor (/quotes/new, /quotes/:id/edit) with add / remove line rows.

Every action is gated on the same predicate the server enforces: Send appears only on an approved quote, Convert only on an accepted one, and the editor is unreachable once the quote leaves draft / rejected. Those predicates live in modules::quotes::status and are unit-tested against the server's rules, so a disabled control means "not in this state" rather than "this might 409".

The detail page carries a one-line explainer per status, because approved (we cleared it to go out) and accepted (the customer signed) are one word apart and mean completely different things. Their badge colours are deliberately different too, and a test pins that.

The editor's running total is explicitly a preview: it recomputes from the half-typed inputs so the number moves as you type, but the server recomputes from the saved lines and what it returns is what the detail page shows. Unparseable input contributes zero rather than erroring, since the field is still being filled in; a test covers "" and "-" mid-keystroke, and that a negative discount line subtracts.

Portal

The client sees the quotes we sent them, opens one to read the scope and lines, and accepts or declines with an optional note.

Reads are already scoped server-side to the contact's company and to issued statuses, so this surface does no filtering of its own: anything it can fetch, it is allowed to show. The status palette is correspondingly narrower than the staff one.

Accepting goes through an explicit confirm step rather than firing on the first click. It authorises work at a price and the client cannot reverse it from the portal. Once decided, expired, or converted, the page shows the outcome and what it means instead of controls that would come back 409.

Notable refactor

ApprovalsSection was hardcoded to /tickets/{id}/approvals, but the approvals surface is polymorphic server-side. Rather than fork a quotes copy, it now takes entity_id + entity_segment + entity_noun, with the last two defaulted so the original ticket call site changes by a single word and keeps its copy. Quotes points it at the quote target.

Verify

just pre-commit passes in the pinned CI image: cargo fmt --all --check, cargo clippy --all-targets -- -D warnings, cargo check --target wasm32-unknown-unknown, and cargo test --lib (222 tests, including 6 new ones).

The new tests cover the parts with real logic rather than markup: that every server status has a label and an explainer, that the action predicates match the server's state machine (send is approved-only, convert is accepted-only, content editing stops at submitted, cancel is refused once issued), that internal and client outcomes get distinct badge colours, and the line-total preview behaviour above.

Worth an eye during review: I have exercised this against the compiler and the unit tests, not yet clicked through it against a live server. The branch is checked out on the dev container so it can be driven end to end.

## What PMS-675, the final phase of the PMS-670 Quotes epic and the only one in this repo. Adds the Quotes UI: staff list / detail / editor, and the client-facing portal view where a quote is accepted or declined. Server side (PMS-671 through PMS-674) is already merged in `mokosh-server`. ## Staff - **List** at `/quotes`, filtered by status and company, with a status column. - **Detail** showing scope, line items, totals, the approvals section, and the actions valid in the current state. - **Editor** (`/quotes/new`, `/quotes/:id/edit`) with add / remove line rows. Every action is gated on the same predicate the server enforces: Send appears only on an `approved` quote, Convert only on an `accepted` one, and the editor is unreachable once the quote leaves `draft` / `rejected`. Those predicates live in `modules::quotes::status` and are unit-tested against the server's rules, so a disabled control means "not in this state" rather than "this might 409". The detail page carries a one-line explainer per status, because `approved` (we cleared it to go out) and `accepted` (the customer signed) are one word apart and mean completely different things. Their badge colours are deliberately different too, and a test pins that. The editor's running total is explicitly a preview: it recomputes from the half-typed inputs so the number moves as you type, but the server recomputes from the saved lines and what it returns is what the detail page shows. Unparseable input contributes zero rather than erroring, since the field is still being filled in; a test covers `""` and `"-"` mid-keystroke, and that a negative discount line subtracts. ## Portal The client sees the quotes we sent them, opens one to read the scope and lines, and accepts or declines with an optional note. Reads are already scoped server-side to the contact's company and to issued statuses, so this surface does no filtering of its own: anything it can fetch, it is allowed to show. The status palette is correspondingly narrower than the staff one. **Accepting goes through an explicit confirm step** rather than firing on the first click. It authorises work at a price and the client cannot reverse it from the portal. Once decided, expired, or converted, the page shows the outcome and what it means instead of controls that would come back 409. ## Notable refactor `ApprovalsSection` was hardcoded to `/tickets/{id}/approvals`, but the approvals surface is polymorphic server-side. Rather than fork a quotes copy, it now takes `entity_id` + `entity_segment` + `entity_noun`, with the last two defaulted so the original ticket call site changes by a single word and keeps its copy. Quotes points it at the quote target. ## Verify `just pre-commit` passes in the pinned CI image: `cargo fmt --all --check`, `cargo clippy --all-targets -- -D warnings`, `cargo check --target wasm32-unknown-unknown`, and `cargo test --lib` (222 tests, including 6 new ones). The new tests cover the parts with real logic rather than markup: that every server status has a label and an explainer, that the action predicates match the server's state machine (send is approved-only, convert is accepted-only, content editing stops at `submitted`, cancel is refused once issued), that internal and client outcomes get distinct badge colours, and the line-total preview behaviour above. Worth an eye during review: I have exercised this against the compiler and the unit tests, not yet clicked through it against a live server. The branch is checked out on the dev container so it can be driven end to end.
Mirrors mokosh-server's quote shapes (PMS-671 through PMS-674): the quote and line responses, the create / update / convert / decision request bodies, and the status vocabulary.

The `status` predicates are the client-side mirror of the server's state machine (`can_send` is approved-only, `can_convert` is accepted-only, content editing stops at `submitted`). They exist so a control is never offered in a state the server would 409, which is the difference between a UI that looks broken and one that reads as deliberate. Unit tests pin them against the server's rules so the two cannot drift silently.

Line `quantity` / `unit_price` are sent as strings: the server takes `Decimal`, and a JSON number would lose precision on the way through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The approvals surface is polymorphic server-side (`target` = ticket | change_request | quote | time_entry), but this component was hardcoded to `/tickets/{id}/approvals`. Quotes need the same section, so it takes the entity rather than being forked per entity.

`entity_segment` and `entity_noun` are defaulted, so the original ticket call site changes by one word and keeps its existing copy. The submit closure clones the segment per invocation for the same reason it already cloned the id: it is an `onclick` handler and must stay `FnMut`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
List with status + company filters, a detail page, and a create/edit form with line items.

Every action is gated on the same predicate the server enforces, so Send appears only on an `approved` quote, Convert only on an `accepted` one, and the editor is unreachable once the quote leaves `draft` / `rejected`. The detail page also carries a one-line explainer per status, because `approved` (we cleared it to go out) and `accepted` (the customer signed) are a word apart and mean completely different things.

The editor's running total is explicitly a preview: it is computed from the half-typed inputs so the number moves as you type, but the server recomputes from the saved lines and what it returns is what the detail page shows. Unparseable input contributes zero rather than erroring, since the field is still being filled in.

Internal sign-off reuses the shared approvals section pointed at the quote target rather than a quotes-specific copy.

Finance-gated with the same `PermissionRequired` state the other billing surfaces use, matching the server's `RequireBilling` + `RequireFinance` pair.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The client-facing half of the PMS-673 flow: the contact sees the quotes we sent them, opens one to read the scope and line items, and accepts or declines with an optional note.

Reads are already scoped server-side to the contact's company and to quotes that were actually issued, so this surface does no filtering of its own; anything it can fetch, it is allowed to show. The status palette is correspondingly narrower than the staff one, because the internal states never reach the portal.

Accepting goes through an explicit confirm step rather than firing on the first click: it authorises work at a price and the client cannot reverse it from here. Once a quote is decided, expired, or converted, the page shows the outcome and what it means instead of controls that would come back 409.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(quotes): routes and Quotes nav entry (PMS-675)
All checks were successful
Check / fmt + clippy + tests (pull_request) Successful in 1m36s
Create release / Create release from merged PR (pull_request) Has been skipped
201024c70f
Staff routes under `/quotes` and client routes under `/portal/quotes`, plus the thin wrapper components the Routable derive resolves against.

Quotes sits at the top of the "Contracts & Billing" nav section, ahead of Rate Cards and Invoices, because it is where the commercial lifecycle starts. Detail, edit, and new all map back to the list in `section_route` so the nav item stays highlighted on those pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/PMS-675-quotes-ui 2026-07-21 20:43:50 +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-apps!436
No description provided.