Files
member-console/internal/embeds/templates/partials/operator_enrollment.html
T
cgalo5758 dd3962990b Adopt entity keys and add invoice numbers
Replace the entity slugs on organizations, workspaces, resource pools,
and
plan ladders with nullable `key` columns and add keys to products,
prices,
and entitlement sets. Rename `providers.slug` to `provider` and add
partial
unique indexes for system and org role names.

Assign invoice numbers per billing account from a gapless transactional
counter; Stripe's number moves to the invoice mapping as an external
reference.

Seeds, fixtures, and the operator lookup address rows by key, and the
returning-login resync no longer blanks a display name when the IdP
sends
no `name` claim.
2026-08-29 20:12:04 -05:00

547 lines
30 KiB
HTML

{{/* Organization detail partial for operator. Titled by the organization's
own display name — the retired working title named this page for the
grant-delivery mechanism instead of the organization it describes
(plan-enrollment-administration D5). This is a Go-template comment
(stripped at render), not an HTML comment, so its own wording never
leaks into the rendered page. */}}
{{ if .Error }}
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> {{ .Error }}
</div>
{{ end }}
<nav aria-label="breadcrumb" class="small text-muted mb-2">
<a href="/operator/organizations">Organizations</a> → {{ .OrgName }}
</nav>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0 h6">{{ .OrgName }}</h1>
<small class="text-muted d-block">
Owner:
{{ if .OrgOwnerPersonID }}
<a href="/operator/persons/{{ .OrgOwnerPersonID }}">{{ if .OrgOwnerName }}{{ .OrgOwnerName }}{{ else }}{{ .OrgOwnerPersonID }}{{ end }}</a>
{{ else }}
<span class="text-muted"></span>
{{ end }}
</small>
</div>
{{/* Read-only key, muted code style, shown only when the organization has
one. Personal organizations never do; the System tenant and rows a
seed or a configuration file named are the ordinary cases. */}}
{{ if .OrgKey }}<span class="text-muted small">Key <code class="text-muted small">{{ .OrgKey }}</code></span>{{ end }}
</div>
{{/* Section heading rule (maintainer design round 2026-08-24): every
section names itself with an OUTSIDE heading, its primary action
right-aligned on the heading row; card titles only ever name
instances (a pool's name), never sections. */}}
<!-- Billing -->
<div class="d-flex justify-content-between align-items-center mt-4 mb-2">
<h2 class="h6 mb-0">Billing</h2>
{{ if .BillingSummary.HasAccount }}
<a class="btn btn-outline-secondary btn-sm" href="/operator/billing/accounts">Full billing →</a>
{{ end }}
</div>
<div class="card mb-3">
<div class="card-body">
{{ if not .BillingSummary.HasAccount }}
<p class="text-muted small mb-0">No billing account exists for this organization yet.</p>
{{ else }}
<div class="row g-3">
<div class="col-md-4">
<div class="text-muted small">Subscription</div>
{{ if .BillingSummary.HasSubscription }}
<div>
<span class="badge bg-secondary">{{ .BillingSummary.SubscriptionStatus }}</span>
{{ if .BillingSummary.SubscriptionPeriodEnd }}
<small class="text-muted">· renews {{ .BillingSummary.SubscriptionPeriodEnd }}</small>
{{ end }}
{{ if .BillingSummary.CancelAtPeriodEnd }}
<small class="text-warning">· cancels at period end</small>
{{ end }}
</div>
{{ else }}
<div class="text-muted small">No subscription</div>
{{ end }}
</div>
<div class="col-md-4">
<div class="text-muted small">Latest invoice</div>
{{ if .BillingSummary.HasInvoice }}
<div>
{{ if .BillingSummary.LatestInvoicePaid }}
<span class="badge text-bg-success">paid</span>
{{ else }}
<span class="badge text-bg-warning">unpaid</span>
{{ end }}
{{ if .BillingSummary.LatestInvoiceNumber }}
<code>{{ .BillingSummary.LatestInvoiceNumber }}</code>
{{ else }}
<span class="text-body-secondary" title="Assigned when the invoice is issued."></span>
{{ end }}
<strong>{{ .BillingSummary.LatestInvoiceAmountDue }}</strong>
<small class="text-muted">· {{ .BillingSummary.LatestInvoiceDate }}</small>
</div>
{{ else }}
<div class="text-muted small">No invoices issued</div>
{{ end }}
</div>
<div class="col-md-4">
<div class="text-muted small">Outstanding balance</div>
<div>
{{ if gt .BillingSummary.OutstandingBalance 0 }}
<strong class="text-warning">{{ .BillingSummary.OutstandingBalanceFormatted }}</strong>
<small class="text-muted">across open invoices</small>
{{ else }}
<strong class="text-success">0</strong>
<small class="text-muted">no open invoices</small>
{{ end }}
</div>
</div>
</div>
{{ end }}
</div>
</div>
<!-- Pools -->
<h2 class="mt-4 h6">Pools</h2>
{{ if .PoolMissing }}
<!-- Every organization gets exactly one default pool at provisioning; one
with none is broken, not merely empty (ux-honest-surfaces: "a pool-less
organization is presented as broken, not empty"). The Issue Grant
section below renders the same reason instead of a submittable form. -->
<div class="alert alert-danger" role="alert">
<strong>Missing default resource pool.</strong> This organization has no
resource pool. Every organization should have exactly one; without it,
grant delivery is impossible until the pool is repaired.
</div>
{{ else if eq (len .Pools) 0 }}
<div class="text-center py-3">
<p class="text-muted mb-0">No pools for this organization.</p>
</div>
{{ else }}
{{ range .Pools }}
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h3 class="card-title mb-1 h6">
{{ .PoolName }} <span class="badge bg-secondary">{{ .PoolType }}</span>
{{ if eq .Status "active" }}
<span class="badge text-bg-success">{{ .Status }}</span>
{{ else }}
<span class="badge text-bg-danger">{{ .Status }}</span>
{{ end }}
</h3>
{{ if ne .Status "active" }}
<p class="text-danger small mb-1">This pool is not active; it is not delivering entitlements.</p>
{{ end }}
{{ if not .HasAttachment }}
<p class="text-muted mb-0">No active plan</p>
{{ end }}
</div>
</div>
{{/* One block per DELIVERY (provision), grouped: the product
renders once with the single Extend control, and its ladder
placements render as rung pills beneath (maintainer design
round 2026-08-24 — a shared product used to render one line
per ladder with a "Same delivery" marker; the grouping
conveys that structurally instead). The Extend form is
pre-scoped via provision_id; a delivery that is not
grant-backed gets a disabled control with the reason. */}}
{{ $pool := . }}
{{ range .Deliveries }}
<div class="d-flex justify-content-between align-items-start mt-2">
<div>
<p class="mb-0"><strong>{{ .ProductName }}</strong></p>
<p class="mb-0 small text-muted">
{{ range $i, $rung := .Rungs }}{{ if $i }} · {{ end }}<span title="since {{ $rung.ActivatedAt }}"><span class="badge bg-primary">{{ $rung.LadderName }}</span> rank {{ $rung.Rank }}</span>{{ end }}
</p>
</div>
<div class="flex-shrink-0">
{{ if .GrantBacked }}
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#extendPanel-{{ .ProvisionID }}" aria-expanded="{{ if and $.FieldErrors (eq $.FormName "extend") (eq $.FormPoolID .ProvisionID) }}true{{ else }}false{{ end }}" aria-controls="extendPanel-{{ .ProvisionID }}">Extend tier</button>
{{ else }}
<span title="Nothing to extend: this delivery is not grant-backed (it may be subscription-billed). Use Issue grant instead."><button type="button" class="btn btn-outline-primary btn-sm" disabled>Extend tier</button></span>
{{ end }}
</div>
</div>
{{ end }}
{{ if gt (len .Usage) 0 }}
<div class="table-responsive mt-2">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Resource</th>
<th>Used</th>
<th>Limit</th>
</tr>
</thead>
<tbody>
{{ range .Usage }}
<tr{{ if ge .Used .Limit }} class="text-danger"{{ end }}>
<td><code>{{ .ResourceKey }}</code></td>
<td>{{ .Used }}</td>
<td>{{ .Limit }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ end }}
{{ range .Deliveries }}
{{ if .GrantBacked }}
<div class="collapse{{ if and $.FieldErrors (eq $.FormName "extend") (eq $.FormPoolID .ProvisionID) }} show{{ end }} mt-3" id="extendPanel-{{ .ProvisionID }}">
<div class="border rounded p-3">
<p class="text-muted small mb-2">
{{ if gt (len .Rungs) 1 }}One extension refreshes all {{ len .Rungs }} ladders shown.
{{ end }}Set <em>Expires at</em> to extend a trial, or leave it empty for an open-ended grant.
</p>
{{ $extendActive := and (eq $.FormName "extend") (eq $.FormPoolID .ProvisionID) }}
<form autocomplete="off" hx-post="{{ routeURL "/partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend" $.OrgID $pool.PoolID }}"
hx-target="#operator-body" hx-swap="innerHTML"
hx-disabled-elt="find button[type=submit]"
hx-indicator="find .htmx-indicator">
<input type="hidden" name="provision_id" value="{{ .ProvisionID }}">
<div class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label" for="extend-reason-{{ .ProvisionID }}">Reason</label>
{{ $err := fieldErr $.FormName "extend" $.FormPoolID .ProvisionID $.FieldErrors "reason" }}
<input autocomplete="off" type="text" id="extend-reason-{{ .ProvisionID }}" name="reason" value="{{ if $extendActive }}{{ index $.FormValues "reason" }}{{ end }}"
class="form-control {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}" placeholder="Why extend?" required>
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
</div>
<div class="col-md-4">
<label class="form-label" for="extend-valid-until-{{ .ProvisionID }}">Expires at <span class="text-muted">(optional)</span></label>
{{ $err := fieldErr $.FormName "extend" $.FormPoolID .ProvisionID $.FieldErrors "valid_until" }}
<input type="datetime-local" id="extend-valid-until-{{ .ProvisionID }}" name="valid_until" value="{{ if $extendActive }}{{ index $.FormValues "valid_until" }}{{ end }}"
class="form-control {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}">
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-outline-primary w-100">
<span class="htmx-indicator spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
Extend tier
</button>
</div>
</div>
</form>
</div>
</div>
{{ end }}
{{ end }}
</div>
</div>
{{ end }}
{{ end }}
<!-- Issue grant and Extend tier moved into the Plan and grants card
and the pool cards respectively (maintainer 2026-08-23: actions live
where their subject is shown; Extend rides the tier it extends). -->
<!-- Grants — every grant for the org, annotated with delivery state.
The grants table is intentionally a ledger (design/entitlements/model.md
treats grants as audit records). The Delivery column collapses the
join to pool_provisions / pool_provision_ladders so operators can see
at a glance which row is actually backing the pool right now vs. which
are historical. Destructive actions render only on Live rows; system
defaults always show "System-managed" since they're owned by org-type
config, not per-grant operator action. Same vocabulary and badge
treatment as /operator/grants (design decision 1: one shared query, one
rendering). -->
{{ if .GrantsError }}
<div class="alert alert-danger" role="alert">{{ .GrantsError }}</div>
{{ end }}
{{ if .PoolMissing }}
<!-- ux-honest-surfaces: issuance is blocked with the reason; the Pools
section above carries the full alarm. -->
<div class="alert alert-danger mb-3" role="alert">
<strong>Issue grant unavailable.</strong> This organization is missing
its default resource pool, so grant delivery is impossible until the
pool is repaired. See the Resource Pools section above.
</div>
{{ else if .ProductsError }}
<div class="alert alert-danger" role="alert">{{ .ProductsError }}</div>
{{ end }}
<h2 class="mt-4 h6">Plan and grants</h2>
{{/* The Issue grant control sits inside the card because its disclosure
opens inside it (maintainer 2026-08-24: disclosure actions live in
the box they disclose into; navigation actions like Full billing
stay on the heading row). It shares the card header with the
Active/History tabs. The #plan-grants-panel wrapper is the scoped
swap target for the ledger's tab/search/pager controls (GrantsNav
.Target), so using them re-renders only this panel and never
scrolls the page. */}}
<div id="plan-grants-panel">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center gap-2">
{{ template "listTabs" .GrantsNav }}
<div class="flex-shrink-0">
{{ if or .PoolMissing .ProductsError }}
<span title="{{ if .PoolMissing }}Unavailable: this organization is missing its default resource pool.{{ else }}Unavailable: products could not be loaded.{{ end }}"><button type="button" class="btn btn-outline-primary btn-sm" disabled>Issue grant</button></span>
{{ else if eq (len .IssuanceProducts) 0 }}
<span title="No published products yet. Publish a product to enable issuance."><button type="button" class="btn btn-outline-primary btn-sm" disabled>Issue grant</button></span>
{{ else }}
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#issueGrantPanel" aria-expanded="{{ if and .FieldErrors (eq .FormName "issue") }}true{{ else }}false{{ end }}" aria-controls="issueGrantPanel">Issue grant</button>
{{ end }}
</div>
</div>
<div class="card-body">
{{ if and (not .PoolMissing) (not .ProductsError) (gt (len .IssuanceProducts) 0) }}
{{/* Collapsed by default; forced open when this form's submission
just failed so field errors are never hidden. */}}
<div class="collapse{{ if and .FieldErrors (eq .FormName "issue") }} show{{ end }} mb-3" id="issueGrantPanel">
<div class="border rounded p-3">
<p class="text-muted small mb-2">
Records the grant and updates this organization's entitlements
right away. If the product occupies a plan-ladder tier, the
pool moves to it; leave <em>Valid until</em> blank for an
open-ended grant.
</p>
{{ $issueActive := and (eq $.FormName "issue") (eq $.FormPoolID "") }}
{{ $formErr := fieldErr $.FormName "issue" $.FormPoolID "" $.FieldErrors "" }}
{{ with $formErr }}
<div class="alert alert-danger" role="alert">{{ . }}</div>
{{ end }}
<form autocomplete="off" hx-post="{{ routeURL "/partials/operator/organizations/{orgID}/grant/create" .OrgID }}"
hx-target="#operator-body" hx-swap="innerHTML"
hx-disabled-elt="find button[type=submit]">
<div class="row g-2">
<div class="col-md-6 mb-2">
<label class="form-label form-label-sm" for="issue-product">Product</label>
{{ $err := fieldErr $.FormName "issue" $.FormPoolID "" $.FieldErrors "product_id" }}
<select id="issue-product" name="product_id" class="form-select form-select-sm {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}" required>
<option value="">Select product…</option>
{{ range $.IssuanceProducts }}
<option value="{{ .ProductID }}" {{ if and $issueActive (eq .ProductID (index $.FormValues "product_id")) }}selected{{ end }}>{{ .Name }}{{ if .IsInternal }} [internal]{{ end }}{{ if .SupersedesSubscription }} ⚠{{ end }}</option>
{{ end }}
</select>
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
</div>
<div class="col-md-3 mb-2">
<label class="form-label form-label-sm" for="issue-quantity">Quantity</label>
{{ $err := fieldErr $.FormName "issue" $.FormPoolID "" $.FieldErrors "quantity" }}
<input autocomplete="off" id="issue-quantity" type="number" name="quantity" min="1" max="1000000" value="{{ if $issueActive }}{{ index $.FormValues "quantity" }}{{ else }}1{{ end }}" class="form-control form-control-sm {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}">
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
<div class="form-text">Above 1 only for per-seat products; most products deliver one tier.</div>
</div>
<div class="col-md-3 mb-2">
<label class="form-label form-label-sm" for="issue-valid-until">Valid until <span class="text-muted">(optional)</span></label>
{{ $err := fieldErr $.FormName "issue" $.FormPoolID "" $.FieldErrors "valid_until" }}
<input id="issue-valid-until" type="datetime-local" name="valid_until" value="{{ if $issueActive }}{{ index $.FormValues "valid_until" }}{{ end }}" class="form-control form-control-sm {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}">
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
</div>
</div>
<div class="row g-2">
<div class="col-md-4 mb-2">
<label class="form-label form-label-sm" for="issue-reason">Reason</label>
{{ $err := fieldErr $.FormName "issue" $.FormPoolID "" $.FieldErrors "reason" }}
<select id="issue-reason" name="reason" class="form-select form-select-sm {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}" required>
<option value="">Select reason…</option>
{{ range $.GrantReasons }}
<option value="{{ . }}" {{ if and $issueActive (eq . (index $.FormValues "reason")) }}selected{{ end }}>{{ . }}</option>
{{ end }}
</select>
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
</div>
<div class="col-md-8 mb-2">
<label class="form-label form-label-sm" for="issue-description">Description <span class="text-muted">(optional)</span></label>
{{ $err := fieldErr $.FormName "issue" $.FormPoolID "" $.FieldErrors "description" }}
<textarea id="issue-description" name="description" rows="1" maxlength="500" class="form-control form-control-sm {{ if $err }}is-invalid{{ end }}" aria-invalid="{{ if $err }}true{{ else }}false{{ end }}" placeholder="Audit note — why this grant?">{{ if $issueActive }}{{ index $.FormValues "description" }}{{ end }}</textarea>
{{ with $err }}<div class="invalid-feedback">{{ . }}</div>{{ end }}
</div>
</div>
{{ $superseders := false }}
{{ range $.IssuanceProducts }}{{ if .SupersedesSubscription }}{{ $superseders = true }}{{ end }}{{ end }}
{{ if $superseders }}
<div class="alert alert-warning py-1 px-2 small mb-2" role="alert">
Products marked ⚠ would supersede a tier currently held by a subscription;
the subscription keeps billing until it is changed in the billing view.
</div>
{{ end }}
<p class="text-muted small mb-2">
Connected services (for example forum or site access) apply
the change in the background and can retry or fail; the
<a href="/operator">overview</a>'s system panel shows queued
work for services that use the delivery queue.
</p>
<button type="submit" class="btn btn-primary btn-sm">Issue grant</button>
</form>
</div>
</div>
{{ end }}
{{/* Two tabs over one ledger (maintainer design round 2026-08-24):
Active (default) shows only grants delivering right now;
History is every grant ever issued with the supersession
audit trail. Search matches product or reason on both tabs;
tab, q, page and per live on the org URL so views deep-link. */}}
{{ if eq .GrantsNav.Facet "history" }}
<p class="text-muted small mb-2">
One row per grant ever issued. The Delivery column shows which
grants are delivering right now; superseded and inactive rows
are history.
</p>
{{ end }}
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
{{ template "listSearch" .GrantsNav }}
</div>
{{ if gt (len .Grants) 0 }}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Product</th>
<th>Reason</th>
<th>Delivery</th>
<th>Qty</th>
<th>Activated</th>
<th>Ended</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .Grants }}
<tr{{ if ne .DeliveryState "live" }} class="text-muted"{{ end }}>
<td>{{ if .ProductName }}{{ .ProductName }}{{ else }}—{{ end }}</td>
<td><span class="badge bg-secondary">{{ .GrantReason }}</span></td>
<td>
{{ if eq .DeliveryState "live" }}
<span class="badge text-bg-success">Live</span>
{{ if .ExtendsLabel }}<br><small class="text-muted">extends {{ .ExtendsLabel }}</small>{{ end }}
{{ else if eq .DeliveryState "superseded" }}
<span class="badge text-bg-secondary">Superseded</span>
{{ if .ReplacedByLabel }}<br><small class="text-muted">replaced by {{ .ReplacedByLabel }}</small>{{ end }}
{{ else }}
<span class="badge text-bg-light border">Inactive</span>
{{ end }}
</td>
<td>{{ .Quantity }}</td>
<td><small>{{ if .ActivatedAt }}{{ .ActivatedAt }}{{ else }}—{{ end }}</small></td>
<td><small>{{ if .EndedAt }}{{ .EndedAt }}{{ else }}—{{ end }}</small></td>
<td>
{{ if eq .DeliveryState "live" }}
{{ if eq .GrantReason "default" }}
<span class="text-muted small" title="System-managed default. Change the org type's default ladder to remove.">System-managed</span>
{{ else }}
<button type="button" class="btn btn-outline-danger btn-sm"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="{{ routeURL "/partials/operator/grants/{grantID}/revoke-and-transition" .GrantID }}"
data-action-method="post"
data-action-target="#operator-body"
data-action-title="Revoke grant"
data-action-body="{{ if .ProductName }}Revoke {{ .ProductName }} for this organization?{{ else }}Revoke this grant for this organization?{{ end }} The pool returns to its org-type default and a downgrade or end transition is recorded."
data-action-confirm-label="Revoke grant"
data-action-fields='{"org_id":"{{ $.OrgID }}"}'>Revoke</button>
{{ end }}
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ template "listPager" .GrantsNav }}
{{ else if .GrantsNav.Q }}
{{ template "listNoMatch" .GrantsNav }}
{{ else if eq .GrantsNav.Facet "history" }}
<div class="text-muted small py-2">No grants issued yet.</div>
{{ else }}
<div class="text-muted small py-2">No grants are delivering right now. The History tab lists every grant ever issued.</div>
{{ end }}
</div>
</div>
</div>
<!-- Members -->
<h2 class="mt-4 h6">Members</h2>
<div class="card mb-3">
<div class="card-body">
{{ if .MembersError }}
<div class="alert alert-danger" role="alert">{{ .MembersError }}</div>
{{ end }}
{{ if eq (len .Members) 0 }}
<div class="text-muted small py-2">No members.</div>
{{ else }}
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Joined</th>
</tr>
</thead>
<tbody>
{{ range .Members }}
<tr>
<td><a href="/operator/persons/{{ .PersonID }}">{{ .DisplayName }}</a></td>
<td><small class="text-muted">{{ .Email }}</small></td>
<td><span class="badge bg-secondary">{{ .RoleName }}</span></td>
<td><small>{{ .JoinedAt }}</small></td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ if .MembersCapped }}
<p class="text-muted small mb-0 mt-2">Showing the latest 16 of {{ .MembersTotal }}.</p>
{{ end }}
{{ end }}
</div>
</div>
<!-- Tier changes -->
{{/* Formerly "Position history"; maintainer design round 2026-08-24:
rows carry the ladder key, humanized verbs, and tier NAMES instead
of integer ranks (Go-template comment so it never ships). */}}
<h2 class="mt-4 h6">Tier changes<span title="Every tier change on this organization's plan ladders, newest first. Tier names reflect the ladder's current shape."><svg class="form-help-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span></h2>
{{/* #tier-changes-panel: scoped swap target for this list's pager, so
paging never scrolls the page (TierChangesNav.Target). */}}
<div id="tier-changes-panel">
<div class="card mb-3">
<div class="card-body">
{{ if .TransitionsError }}
<div class="alert alert-danger" role="alert">{{ .TransitionsError }}</div>
{{ end }}
{{ if eq (len .Transitions) 0 }}
<div class="text-center py-3">
<p class="text-muted mb-0">No tier changes recorded.</p>
</div>
{{ else }}
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead>
<tr>
<th>Ladder</th>
<th>Change</th>
<th>From</th>
<th>To</th>
<th>By</th>
<th>Reason</th>
<th>When</th>
</tr>
</thead>
<tbody>
{{ range .Transitions }}
<tr>
<td><span class="badge bg-primary">{{ .LadderName }}</span></td>
<td>{{ if .ChangeTooltip }}<span class="badge text-bg-info" title="{{ .ChangeTooltip }}">{{ .ChangeLabel }}</span>{{ else }}<span class="badge text-bg-info">{{ .ChangeLabel }}</span>{{ end }}</td>
<td>{{ .FromLabel }}</td>
<td>{{ .ToLabel }}</td>
<td>
{{ if .ActorName }}{{ .ActorName }}{{ else }}<em class="text-muted">{{ .ActorType }}</em>{{ end }}
</td>
<td><small>{{ .Reason }}</small></td>
<td><small>{{ .EffectiveAt }}</small></td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ template "listPager" .TierChangesNav }}
{{ end }}
</div>
</div>
</div>