Project

General

Profile

Actions

Feature #1527

closed
RA

Feature #1510: Implement internal user roles with ticket progression and assignment controls

Ticket panel: Move Forward and a role-correct Change Status control

Feature #1527: Ticket panel: Move Forward and a role-correct Change Status control

Added by Redmine Admin 4 days ago. Updated 4 days ago.

Status:
Shipped
Priority:
Normal
Assignee:
-
Start date:
07/31/2026
Due date:
% Done:

0%

Estimated time:
spec_ref:

docs/superpowers/specs/2026-07-31-internal-roles-design.md

build_status:
build_number:
ci_run_url:
scan_status:
scan_report_url:
deploy_status:
preprod_url:
deployed_at:
branch:
pr_url:
security_key:
severity:
paused:

Description

**Surface:** frontend-only

First half of the frontend. A designer-agent section is appended to `docs/design/design-system.md` first, then `TaskDetailPane.tsx` gains a Move Forward action and a Change Status control, both rendered strictly from the server-provided capabilities.

The existing `useTransitionTask` hook (`src/features/tasks/useTransitionTask.ts`) is finally rendered — it has existed and been wired to a working route all along, but nothing has ever used it.

**The dropdown must not offer a developer anything the server would refuse:** a developer sees exactly the single next status (or no dropdown at all, leaving Move Forward as the only door); cto/admin see the full allowed set, forward and backward.

No status order and no permission rule may be duplicated in `src/`.

## Acceptance criteria
- [ ] A design-system section for these controls exists **before** the code lands, and the built UI matches it.
- [ ] FE types carry `internal_role`, `next_status_id`, `can_move_forward`, `allowed_statuses`; a grep proves no status-order array and no role predicate exists anywhere in `src/`.
- [ ] Move Forward renders only for internal users, is enabled iff `can_move_forward && next_status_id !== null`, and **names the concrete target stage** in its label.
- [ ] The disabled state uses `aria-disabled` (not a bare `disabled`, which drops focusability) with a keyboard-reachable tooltip wired via `aria-describedby`, explaining why — not assigned to you / no next stage.
- [ ] **Developer view:** the Change Status control offers exactly one option — `next_status` — or is hidden entirely with Move Forward left as the only control. A test asserts no other status is reachable from the developer's UI.
- [ ] **cto / admin view:** the control lists every allowed status, and a backward transition can be performed and succeeds.
- [ ] Backlog(16) never appears as a selectable target, in any role.
- [ ] **HARD:** on a Backlog ticket neither Move Forward nor Change Status renders for any role, including CTO and admin — Submit stays the only way out, tested for both.
- [ ] **Developer picks a non-next status → handled gracefully:** with a stale/forced option, submitting a non-next target surfaces the server 403 as an inline, readable error, optimistic state is rolled back and the pane refetches — asserted by test, not left to a toast.
- [ ] A 409 shows the existing invalid-transition message; neither error blanks the pane.
- [ ] An ordinary client sees no status controls at all — the pane is read-only on status exactly as today (regression test).
- [ ] The mutation invalidates both the board and the detail queries so the row re-groups under its new stage.
- [ ] New i18n keys in **both** `en.ts` and `fr.ts` (real French, identical ICU placeholders/plural branches).
- [ ] Vitest covers CTO, Developer-assigned, Developer-not-assigned, ordinary client, the backlog case, and the 403/409 renders.
- [ ] `npx vitest run && npx tsc --noEmit && npx eslint . && npm run build` all green.


Files

design-1527-before.png (296 KB) design-1527-before.png Redmine Admin, 07/31/2026 06:11 PM
design-1527-after.png (1.72 MB) design-1527-after.png Redmine Admin, 07/31/2026 06:11 PM
design-1527-mockup.html (24.6 KB) design-1527-mockup.html Redmine Admin, 07/31/2026 06:11 PM
design-1527-before-reconstruction.html (6.91 KB) design-1527-before-reconstruction.html Redmine Admin, 07/31/2026 06:11 PM
design-1527-before.png
design-1527-after.png

RA Updated by Redmine Admin 4 days ago Actions #1

  • Status changed from Backlog to Submitted

RA Updated by Redmine Admin 4 days ago Actions #2

  • Status changed from Submitted to Spec
  • spec_ref updated (diff)

RA Updated by Redmine Admin 4 days ago Actions #7

# Design brief — §IR "Internal roles — ticket progression & assignment"

Design-system section appended (additive, +349 lines, 0 deletions): `docs/design/design-system.md` → **§IR**, at the end of the file, in the house format of §TD3 / §CL. It covers **#1527 and #1528 together**, on purpose: both land in the same hero/action region of `TaskDetailPane.tsx`, and designed apart they would fight for the same eight centimetres — the hero row already carries *Submit → Claude → Edit → Pause*, and three more controls would take it to seven and bury the client's only CTA.

**Proof attached:** `design-1527-before.png` · `design-1527-after.png` · `design-1527-mockup.html` (the after mockup, self-contained) · `design-1527-before-reconstruction.html`.

## The two rules everything follows

1. **Rendered from the server's answer, never from a copy of the rules.** No status order and no role predicate in `src/`. Three questions, one field each: *may I advance?* → `can_move_forward`; *where to?* → `next_status_id` / `next_status_name`; *what else may I set?* → `allowed_statuses`, which the server sends **already narrowed to what this viewer may set** (`may_set_status` per candidate target, Backlog stripped in both directions).
2. **A client's pane stays byte-for-byte today's.** Nothing is inserted into `TicketHero` at all — the whole feature mounts as **one sibling band**. For a client the band returns `null`, so the diff to their screen is provably empty in the rendered tree, not a promise about prop plumbing.

`internal_role` from `/auth/me` is deliberately **not** the gate on this screen. Using it would mean the client's payload still carried `assigned_to_name` and the frontend merely declined to paint it — the staff name would sit in the JSON, in the React Query cache and in devtools. Spec decision 7 is about *not shipping the name*, so the gate is **payload presence**: `undefined` = you may not see it, `null` = nobody holds it → "Unassigned".

## Atomic Design — new vs reused

| Component | Level | New? |
|---|---|---|
| `Button`, `Badge`, `TaskStatusPill` | atoms | reused unchanged |
| `Tooltip` §UH.2 | atom | **extended**: optional `id` → drops `aria-hidden`, becomes the `aria-describedby` target (a reason is a description, not a name) |
| `MoveForwardButton` | **molecule** | **new** |
| `TransitionControl` §TB.6 | molecule | **reused, mounted for the first time ever**; `error: boolean` → `error?: string \| null` so 403 and 409 read differently |
| `AssigneeControl` | **molecule** | **new** |
| `TicketFacts` §TD3.7 | molecule | reused unchanged — the caller composes a new "Assigned to" row |
| `TicketHero` §TD3.3 | molecule | **untouched** |
| `StaffActionBar` | **organism** | **new** — the single mount point |
| `TaskDetailPane` | organism | one new sibling in the story column + the rail row |

**`StaffActionBar`** — a recessed band (`rounded-xl border border-border bg-surface-muted`) directly under the hero, sibling to it, after the §CL.2a Claude fallback mount (that rule is not re-opened). Eyebrow label, **not** a heading, so no rung is inserted between the `h1` and the description's `h3`s. Order by frequency, not power: Move Forward (the only `primary` in the band) → Change Status → Assign. Deliberately **not** mirrored into `TicketStickyHeader` — deferred, noted.

## The four viewer states

| Viewer | Move Forward | Change Status | Assign | Assignee name |
|---|---|---|---|---|
| **Ordinary client** (`internal_role: null`) | absent | absent | absent | **absent from the payload** |
| **Developer, assigned** | enabled, *"Move to Design"* | **absent** | absent | rail row only |
| **Developer, not assigned** | present, `aria-disabled`, tooltip | absent | absent | rail row only |
| **CTO / admin** | enabled | full set, backward included | present | trigger + rail |
| **Backlog, any viewer incl. CTO/admin** | **absent** | **absent** | present iff `can_assign` | as above |

## The two decisions you asked for

**1. Developer's one-option Change Status → hide it, Move Forward is the only door.** Render rule, one line, holding no policy: `!isBacklog && allowed_statuses.length >= 2`. The server sends a developer exactly `[next_status]` → length 1 → not rendered; a client `[]`; a backlog ticket `[]`. A one-option menu offers a choice that does not exist: three interactions (open, pick, confirm) to reach the identical mutation the button beside it performs in one, sitting next to that button claiming to be a *different* affordance, containing by construction the button's own label. It is also the **weaker guarantee** — a rendered menu is a thing to force open with devtools and make submit a stale target; an unrendered one is nothing at all. Hiding it makes the AC "no other status is reachable from the developer's UI" true *by construction*, and the test becomes a statement about the DOM rather than about a filter's arithmetic. The server still refuses a forced non-next target — belt and braces, not belt alone.

**2. Disabled Move Forward = `aria-disabled` + focusable + `aria-describedby`, never bare `disabled`.** A bare `disabled` drops the button from the tab order, which makes the *only* explanation of why you cannot act unreachable by exactly the people who cannot mouse-hover it. Click handler short-circuits, `opacity-55`, hover suppressed so nothing promises a state change. Two reason shapes, tested in this order — `next_status_id === null` → *"This ticket is at the end of the pipeline."* (a fact about the ticket, true for everyone), else → *"Only the developer assigned to this ticket can move it forward."* If a third reason ever appears server-side it ships as a **reason code on the payload**; the frontend does not grow a third branch.

## Backlog — the credit gate

**No status control renders for anyone, including CTO and admin** — absent, not disabled-with-an-explanation; the pane must not suggest a second door exists next to Submit. Backlog never appears as a *target* either, because the server never puts it in `allowed_statuses` in either direction; the frontend adds **no filter of its own** (a filter here would be a silent second copy of the gate, and the day the two disagreed the visible one would win for the wrong reason). The `isBacklog` guard uses the `BACKLOG_STATUS_ID` constant already in `src/features/tasks/types.ts` — it already gates Submit, so no new knowledge. **The Assign control survives**: assignment changes no status and spends no credit, and lining work up before the client pays is exactly what a CTO does with a backlog.

## Assign (#1528)

Trigger shows the current holder (initials avatar + name, or "Unassigned"). Menu = **"Unassigned" always first**, hairline, then the project's developers, current one suffixed "· current" + `aria-current` (never colour-only). **Two empty shapes, not one:** `[]` and nobody assigned → `aria-disabled` trigger + note *"No developer is configured for this project."*, no menu opens (a menu that opens onto nothing is a bug the user is asked to interpret); `[]` but someone assigned → the menu still opens with just "Unassigned", because removing someone must not depend on the list that could no longer produce them.

## Errors — inline, adjacent, never optimistic

`role="alert"` block inside the band, under the control that produced it. The pane never blanks: the error is about one control, not about the ticket. 403 → `tasks.progress.forbidden` / `tasks.assign.forbidden`; 409 → the existing `tasks.transitionFailed`, unchanged; 422 → `tasks.assign.unmapped` ("{name} has no Redmine account linked yet…"); upstream → `tasks.assign.failed`.

**There is no optimistic state, and that is the design.** The status pill changes only when the server has answered, so the AC's "optimistic state is rolled back" is satisfied in the strongest available way — there is nothing to roll back, because the pane never displayed a stage nobody had agreed to. In-flight feedback is the button's own spinner + `aria-busy`. Both mutations invalidate the board **and** detail queries **on failure as well as success**: a 403 usually means the world moved, so the right response to being refused is to go find out what is true now — which is what makes the 403 copy ("…has been refreshed") a fact rather than a flourish.

## i18n

16 new keys under `tasks.staff.*` / `tasks.progress.*` / `tasks.assign.*`, EN + FR given verbatim in §IR.6, identical ICU placeholders — both catalogs, since `fr.ts` is a real translation typed to mirror `en.ts`. Reused unchanged: `tasks.status.*` (via `statusLabel`, a `stageKey → key` map, **not** an ordering), `tasks.changeStatus*`, `tasks.confirmMove`, `tasks.move`, `tasks.cancel`, `tasks.transitionFailed`.

## Proof — how it was captured (read this before trusting the screenshots)

The gstack **/browse** daemon is **not installed on this machine** (`~/.claude/skills/gstack/browse/dist/browse` does not exist; `/usr/bin/browse` is the xdg-open wrapper). Screenshots were taken with **headless Google Chrome 150** at `--force-device-scale-factor=2` instead. Stating it plainly rather than implying tooling that was not used.

- **AFTER** — `design-1527-after.png`, rendered from the attached mockup: all five cases (client / developer-assigned / developer-not-assigned incl. the focused tooltip / CTO with both menus open / backlog) plus the error strip, each annotated with the exact payload that produces it.
- **BEFORE** — `design-1527-before.png` is an **HTML reconstruction, not a capture of the live app**: `pipeliner.omdev.tech` is session-walled and no local dev server was running. Anatomy taken verbatim from `TicketHero.tsx`, `TicketFacts.tsx` and `TaskDetailPane.tsx`, so it is faithful to the current code, but it is a reconstruction and is labelled as such on the image itself.

**No new colour anywhere** — §2/§3 tokens only. Design-system edit is uncommitted in the shared checkout, as intended; no git action taken.

RA Updated by Redmine Admin 4 days ago Actions #8

  • Status changed from Spec to Design

RA Updated by Redmine Admin 4 days ago Actions #9

  • Status changed from Design to Shipped

Shipped in master build #107 (2026-07-31). `StaffActionBar` renders `null` for an ordinary client, so "a client's panel is byte-for-byte what it was" is a property of the tree, asserted by test. Review caught that the Move Forward button was sourcing its label from the viewer-narrowed `allowed_statuses` — empty for a developer who is not the assignee — so the button vanished in exactly the state its tooltip exists to explain, making the aria-disabled contract dead code. It now labels from `next_status_name`. Design-system §IR.9 records where the implementation departed from §IR and why.

Actions

Also available in: PDF Atom