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.
547 lines
30 KiB
HTML
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>
|