Skip to content

Admin — Catalogue Moderation Queue

The admin's view of the CatalogueSubmission request queue (M17, UC-7) — the surface where Stefan promotes or rejects stringer-submitted Racket and String catalogue entries. Owned by Mira. Cross-cuts: v2-scope M17, use-cases UC-7, data-model — CatalogueSubmission, ADR-0005 § notification kinds, stringer-dashboard § Inbox section, design-tokens. Coordinates with Pax-A (feat/107-catalogue-and-receipt-counter — schema implementation).

Source requirement

  • v2-scope M17 — "Shared catalogue (Strings + Rackets) with admin-moderated promotion via request-queue (stringer submits → admin inbox + unread badge → admin promotes or rejects). No unilateral flag-flip by stringers."
  • use-cases UC-7 — UC-7 explicitly: stringer submits → admin queue → promote or reject.
  • data-model — CatalogueSubmission — schema shape: id, catalogue_kind (racket | string), catalogue_row_id, submitted_by_stringer_id, status (pending | promoted | rejected), reviewed_by_admin_id, reviewed_at, notes, timestamps.
  • ADR-0005 § Notification kindscatalogue_submission_pending (admin recipient) and catalogue_submission_decided (stringer recipient).
  • architecture/integrations.md — confirms admin recipient on submission landing.
  • architecture/auth-and-tenancy.md — admin bypass_tenant is the mechanism for cross-stringer reads of catalogue tables; logged.

Goal

Three commitments:

  1. The queue is a chore, not a workflow. Stefan is the only admin in V2; he runs this maybe once a week to clear the backlog. The UI optimises for fast triage (skim list, decide per-row) over rich detail.
  2. The decision must be auditable but not laborious. Per UC-7, every promote / reject transitions the underlying Racket / String row's visibility. The decision modal is the one surface where the friction lives — explicit reason on reject, optional notes on promote.
  3. The admin's dashboard surface is a count badge, not a separate inbox screen. The unread-badge pattern from M17 / NFR-8 is the entry point; clicking it deep-links into this queue.

Information architecture

The queue is a single admin-only route (/admin/catalogue/queue); the detail view is a sub-route (/admin/catalogue/queue/:submission_id). Outside the V2 admin scope, this surface is invisible to non-admin stringers.

Admin
├── Dashboard (existing)        ←  badge "3 catalogue submissions" links into ↓
└── Catalogue queue (new)
    ├── List view (default)     ←  pending submissions, oldest first
    │   ├── Filter chips        ←  All | Rackets | Strings
    │   └── Row tap             ←  → detail view
    └── Detail view             ←  one submission at a time
        ├── Submission metadata ←  who, when, notes
        ├── Underlying row      ←  the Racket or String the submission is about
        ├── Promote CTA         ←  flips visibility = shared
        └── Reject CTA          ←  flips status = rejected with reason

The "Decided" archive (promoted / rejected submissions) is reachable via a tab on the list view but defaults to hidden — V2 reality is "review pending, archive forgotten".

Stage 1 — Dashboard badge entry point

Per stringer-dashboard § Inbox section and ADR-0005 § Inbox query, the admin dashboard already surfaces unread notifications. New catalogue_submission_pending rows are unread notifications targeting the admin user.

The dashboard's inbox section grows a distinct catalogue-submissions chip when one or more pending submissions exist (this is admin-visible only):

┌───────────────────────────────────────┐
│  Inbox                          ⓘ     │  H2 — existing
│ ┌───────────────────────────────────┐ │
│ │ 🔔 3 catalogue submissions        │ │  New: admin-only chip
│ │    pending review                 │ │
│ │                       Review →    │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ ➡ Sarah Reber granted you...      │ │  Existing share notifications
│ ⋯                                     │
└───────────────────────────────────────┘

Behaviour:

  • Chip only renders when count(catalogue_submissions WHERE status = 'pending') >= 1.
  • Tapping the chip navigates to /admin/catalogue/queue (List view).
  • Chip background amber-50; left-border amber-700; lucide:bell icon. Differentiates from the gift-icon share notifications.
  • The chip is tappable across its full width; "Review →" is the visible affordance.
  • Non-admins do not see the chip (server-side filter on Stringer.role = 'admin').

Stage 2 — List view

/admin/catalogue/queue

sm 375 px (mobile-first)

┌───────────────────────────────────────┐
│ ←  Catalogue queue                    │  Header
├───────────────────────────────────────┤
│                                       │
│  [ All 3 ]  [ Rackets 1 ]  [ Strings 2 ]│  Filter chips
│                                       │
│  Pending — oldest first               │  H3 muted
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ 🎾 Racket                         │ │  Type tag (text-tiny)
│ │ Wilson Pro Staff X 97             │ │  H3 — manufacturer + model
│ │ 97 sq in · 16×19                  │ │  text-small slate-600
│ │ submitted by Sarah Reber          │ │  text-small slate-500
│ │ 4 days ago — 2026-04-28           │ │  text-tiny slate-500
│ │                                   │ │
│ │      [Reject]      [Promote]      │ │  Per-row inline actions
│ └───────────────────────────────────┘ │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ ⫻ String                          │ │
│ │ Yonex Poly Tour Pro 1.25          │ │
│ │ 1.25 mm gauge                     │ │
│ │ submitted by Marc Egli            │ │
│ │ 2 days ago — 2026-04-30           │ │
│ │                                   │ │
│ │      [Reject]      [Promote]      │ │
│ └───────────────────────────────────┘ │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ ⫻ String                          │ │
│ │ Babolat RPM Soft 1.30             │ │
│ │ 1.30 mm gauge                     │ │
│ │ submitted by Marc Egli            │ │
│ │ today — 2026-05-02                │ │
│ │                                   │ │
│ │      [Reject]      [Promote]      │ │
│ └───────────────────────────────────┘ │
│                                       │
│  Decided →                            │  Link to archive
│                                       │
└───────────────────────────────────────┘

md 768 px+

Two-column variant: filter chips + list on the left (max-width ~480 px), with the detail view as a right-pane preview when a row is tapped (instead of routing to a sub-route). On sm, the list and detail are separate routes.

┌─────────────────────────────────────────────────────────────────┐
│ ←  Catalogue queue                                              │
├──────────────────────────────────┬──────────────────────────────┤
│ [All 3] [Rackets 1] [Strings 2]  │   Detail (selected row)      │
│                                  │   …rendered per Stage 3       │
│ Pending — oldest first           │                              │
│                                  │                              │
│ ┌─────────────────────────────┐  │                              │
│ │ 🎾 Racket                  │  │                              │
│ │ Wilson Pro Staff X 97      │  │                              │
│ │ submitted Sarah Reber      │  │                              │
│ │ 2026-04-28                 │  │                              │
│ │  [Reject]  [Promote]       │  │                              │
│ └─────────────────────────────┘  │                              │
│ ⋯                                │                              │
│                                  │                              │
└──────────────────────────────────┴──────────────────────────────┘

Behaviour

  • Sort order: submitted_at ASC (oldest first). Per Round-3 scope: "oldest first" — gives Stefan a FIFO triage. Per-row date is shown as relative ("4 days ago") + absolute (2026-04-28) — see Open question #2.
  • Filter chips:
  • "All N" (count of pending across both kinds).
  • "Rackets N" (catalogue_kind = 'racket').
  • "Strings N" (catalogue_kind = 'string').
  • Single-select. Default "All". Selecting a chip filters via HTMX (hx-get="/admin/catalogue/queue?kind=racket" hx-target="#queue-list").
  • Per-row inline actions ("Reject" / "Promote") are present on sm and md+. Per Open question #1, these inline buttons short-circuit the detail view for the routine decision; tapping the row body (not the buttons) opens the detail view for cases where Stefan wants the full picture before deciding.
  • Inline Promote opens a one-step confirmation toast with optional notes; inline Reject opens a small modal with required reason (because reject is the more consequential action — the stringer's submission was refused).
  • Empty state (no pending submissions):
┌───────────────────────────────────────┐
│ ←  Catalogue queue                    │
├───────────────────────────────────────┤
│                                       │
│  Nothing pending                      │  H2
│                                       │
│  All catalogue submissions reviewed.  │  text-body slate-600
│  New submissions appear here.         │
│                                       │
│  Decided →                            │  Link to archive
│                                       │
└───────────────────────────────────────┘
  • "Decided" archive link at the bottom of the list — opens /admin/catalogue/queue/decided (a paged list of past promoted and rejected submissions, read-only). V2 implements as a basic list; the archive itself is out of design-spec scope beyond "looks like the pending list, with status chips ('Promoted' green; 'Rejected' red) replacing the action buttons."

Component breakdown

Component Notes
Header "Catalogue queue" — text-h1. Back arrow links to admin dashboard.
Filter chips Pill row, text-small. Selected chip bg-indigo-700 text-white; unselected bg-slate-100 text-slate-700. Counts are part of the chip label: "All 3", "Rackets 1".
Pending H3 "Pending — oldest first" — text-h3 slate-500 uppercase tracking-wide (subtle group label).
Submission row bg-white border border-slate-200 rounded-lg p-4. Type-tag at top (lucide:circle-dot for racket / lucide:zap for string + text-tiny label). H3 with manufacturer + model. text-small line for spec summary (head size + pattern for racket; gauge for string). text-small line for submitter. text-tiny slate-500 line for date.
Type tag "🎾 Racket" / "⫻ String" — icons are decorative; text label carries meaning for screen readers. (Tailwind: inline-flex items-center gap-1 text-tiny font-medium uppercase tracking-wide text-slate-500.)
Per-row actions Two buttons side-by-side, full-row width split 50/50. "Reject" bg-white border border-red-200 text-red-700; "Promote" bg-indigo-700 text-white. Min height 44 px.
"Decided →" link text-small text-indigo-700 underline, bottom of list.
Empty state Centered H2 + body text; same link to archive at bottom.

Pagination

Per the V2 reality (handful of pending submissions at a time), no pagination on the pending tab in V2. The Decided archive paginates at 25 rows per page (offset, per ADR-0005 § Inbox query precedent).

Stage 3 — Detail view

/admin/catalogue/queue/:submission_id

The full-context view of one submission. Used when Stefan taps the row body (not the inline buttons). On md+ this is the right-pane on the list view; on sm it's a separate route.

sm 375 px

┌───────────────────────────────────────┐
│ ←  Submission #142                    │  Header
├───────────────────────────────────────┤
│                                       │
│  🎾 Racket                            │  Type tag
│                                       │
│  Wilson Pro Staff X 97                │  H1 — manufacturer model
│                                       │
│  ─── Specs ───                        │  Group label
│                                       │
│  Manufacturer:  Wilson                │  Spec list — text-small
│  Model:         Pro Staff X 97        │
│  Version:       2024                  │
│  Head size:     97 sq in              │
│  String pattern:16 × 19               │
│                                       │
│  ─── Submission ───                   │
│                                       │
│  Submitted by   Sarah Reber           │
│  Submitted at   2026-04-28 14:22      │
│  4 days ago                           │  text-tiny slate-500
│                                       │
│  Stringer's note                      │  text-small slate-700
│ ┌───────────────────────────────────┐ │
│ │ I've used this with two clients   │ │  Notes from CatalogueSubmission.notes
│ │ this season — would be useful to  │ │  (free text)
│ │ have it in the shared catalogue.  │ │
│ └───────────────────────────────────┘ │
│                                       │
│  ─── Decide ───                       │
│                                       │
│   ┌──────────────────────────────┐    │
│   │  Reject with reason          │    │  Secondary CTA
│   └──────────────────────────────┘    │
│                                       │
│   ┌──────────────────────────────┐    │
│   │  Promote to shared catalogue │    │  Primary CTA
│   └──────────────────────────────┘    │
│                                       │
│   This makes the racket visible to    │  text-tiny slate-500 — context
│   every stringer.                     │
│                                       │
└───────────────────────────────────────┘

Detail-view variants by catalogue_kind

Kind Spec fields displayed
Racket manufacturer, model, version (if set), head_size_sqin, string_pattern, serial_or_instance_id (if set — typically null for catalogue-shareable rackets)
String manufacturer, model, gauge

Each spec row is <dt> (label) + <dd> (value), text-small. The Pax-A schema's submission.catalogue_row_id resolves to the underlying Racket or String row; the detail view JOINs and renders the relevant fields.

Behaviour

  • Stringer's note (CatalogueSubmission.notes) — optional free text. If null, the entire "Stringer's note" block is omitted (not rendered as "(no note)" — keep the page clean).
  • Promote CTA — primary button. Tapping opens the Promote confirmation modal (Stage 4a).
  • Reject CTA — secondary button. Tapping opens the Reject confirmation modal with required reason (Stage 4b).
  • Back arrow — returns to the list view (/admin/catalogue/queue), preserving filter state via query string.
  • Already-decided submissions (e.g. Stefan navigates back to a URL after promotion): the page renders the same metadata + spec list, with the Decide section replaced by a status chip:
  ─── Decided ───

   ✓ Promoted
   on 2026-05-02 09:15
   to "shared" visibility

(Or ✕ Rejected with reason in red.) No CTAs.

Component breakdown

Component Notes
Header "Submission #{id}" — text-h2. Back arrow links to list.
Type tag Same component as list view; rendered above H1.
H1 Manufacturer + model — text-h1. (For strings: "Yonex Poly Tour Pro 1.25" — same shape.)
Group label text-h3 slate-500 uppercase tracking-wide; horizontal rules per design-tokens convention.
Spec definition list <dl> with <dt> width-32 slate-500, <dd> weight-500.
Submitter row "Submitted by {name}" + relative + absolute date.
Note block bg-slate-50 border border-slate-200 rounded-lg p-3 text-small slate-700. Italic.
Reject CTA Full-width; bg-white border border-red-200 text-red-700.
Promote CTA Full-width; bg-indigo-700 text-white.
Decided status chip bg-green-50 text-green-800 (promoted) or bg-red-50 text-red-700 (rejected); lucide:check-circle / lucide:x-circle.

Stage 4a — Promote confirmation modal

   ╔═══════════════════════════════╗
   ║                               ║
   ║  Promote to shared catalogue? ║  H2
   ║                               ║
   ║  Wilson Pro Staff X 97 will   ║  text-body
   ║  become visible to every      ║
   ║  stringer.                    ║
   ║                               ║
   ║  Notes (optional)             ║  text-small slate-700
   ║ ┌─────────────────────────┐   ║
   ║ │                         │   ║  Textarea — admin-side notes
   ║ │                         │   ║
   ║ └─────────────────────────┘   ║
   ║  Stored on the submission     ║  text-tiny slate-500
   ║  for the audit trail.         ║
   ║                               ║
   ║  ┌───────────────────────┐    ║
   ║  │  Cancel               │    ║  Secondary
   ║  └───────────────────────┘    ║
   ║  ┌───────────────────────┐    ║
   ║  │  Promote              │    ║  Primary (red? indigo? — see below)
   ║  └───────────────────────┘    ║
   ║                               ║
   ╚═══════════════════════════════╝

Behaviour

  • Notes are optional. They land in CatalogueSubmission.notes (admin-side note appended to the stringer's submission notes — see Open question #4 on whether to combine or split).
  • Primary CTA "Promote" is bg-indigo-700 text-white (not red — promote is the additive default; reject is the destructive case). Single-tap.
  • On submit: server runs the promote transaction in one transaction:
  • UPDATE catalogue_submissions SET status = 'promoted', reviewed_by_admin_id = :me, reviewed_at = NOW(), notes = COALESCE(notes || '\n\n[admin]: ' || :admin_notes, notes) WHERE id = :submission_id.
  • UPDATE rackets SET visibility = 'shared' WHERE id = :catalogue_row_id (or strings for string submissions).
  • Per ADR-0005 § Catalogue moderation hooks, enqueue catalogue_submission_decided notification to submitted_by_stringer_id.
  • Success state: modal closes; redirect to list view (the row disappears from pending); toast: "Promoted Wilson Pro Staff X 97 to the shared catalogue."

Component breakdown

Component Notes
Modal panel bg-white rounded-xl shadow-xl p-6 max-w-md mx-auto.
H2 "Promote to shared catalogue?" — declarative question.
Body One sentence naming the row + the consequence.
Notes textarea text-body; min-h-20; placeholder empty.
Notes hint text-tiny slate-500.
Cancel bg-white border border-slate-300 text-slate-700.
Promote (primary) bg-indigo-700 text-white.

Stage 4b — Reject confirmation modal

   ╔═══════════════════════════════╗
   ║                               ║
   ║  Reject this submission?      ║  H2
   ║                               ║
   ║  Wilson Pro Staff X 97 will   ║  text-body
   ║  stay private to Sarah Reber. ║
   ║                               ║
   ║  Reason *                     ║  Required label
   ║ ┌─────────────────────────┐   ║
   ║ │                         │   ║  Textarea — required
   ║ │                         │   ║
   ║ └─────────────────────────┘   ║
   ║  Sarah will see this in her   ║  text-tiny slate-500
   ║  notification.                ║
   ║                               ║
   ║  ┌───────────────────────┐    ║
   ║  │  Cancel               │    ║
   ║  └───────────────────────┘    ║
   ║  ┌───────────────────────┐    ║
   ║  │  Reject               │    ║  Primary (red — destructive)
   ║  └───────────────────────┘    ║
   ║                               ║
   ╚═══════════════════════════════╝

Behaviour

  • Reason is REQUIRED (per the round-3 scope: "Reject with reason"). Validation: non-empty; ≤ 500 chars. If empty on submit, modal stays open with red-700 inline error: "Please give a reason."
  • The reason text is shared with the submitting stringer in their catalogue_submission_decided notification. The hint "Sarah will see this in her notification" sets that expectation upfront so the admin writes constructive feedback ("Already in the catalogue as 'Wilson Pro Staff X' (no version suffix)").
  • Primary CTA "Reject" is bg-red-700 text-white (destructive — distinct from Promote). Single-tap.
  • On submit: server runs the reject transaction:
  • UPDATE catalogue_submissions SET status = 'rejected', reviewed_by_admin_id = :me, reviewed_at = NOW(), notes = COALESCE(notes || '\n\n[admin reject reason]: ' || :reason, '[admin reject reason]: ' || :reason) WHERE id = :submission_id.
  • Underlying Racket / String row is NOT modified. It stays at its prior visibility (private_to_stringer or pending); the stringer keeps using it for their own orders.
  • Enqueue catalogue_submission_decided notification to submitted_by_stringer_id with the reject reason in the notification body.
  • Success state: modal closes; redirect to list view; toast: "Rejected. Sarah will be notified."

Why a stronger gate on Reject than Promote

Promote is recoverable in principle (admin can manually flip visibility back to private if it was a mistake — no audit-defying step required); reject sends a notification with a reason and the stringer expects feedback. The reject reason is the explicit-feedback contract.

Notifications surface — how the queue interacts with the inbox

Per ADR-0005:

  • Submission landingcatalogue_submission_pending row written for every admin (V2: just Stefan). Surfaced on the admin dashboard's inbox section as the chip from Stage 1. Marked-read when the admin clicks "Review →" or opens the queue route.
  • Decision dispatchedcatalogue_submission_decided row written for the submitting stringer when the admin promotes or rejects. The stringer sees it in their dashboard inbox; the notification body summarises the decision ("Your submission of Wilson Pro Staff X 97 was promoted to the shared catalogue." / "Your submission was rejected: {reason}.").

These two notification kinds are pre-existing in ADR-0005; this surface only consumes them. No new notification kinds are introduced in Round 3.

Interaction states

State What renders
List — empty (no pending) "Nothing pending" empty state with link to Decided archive.
List — pending exists Filter chips + rows, oldest first.
List — filter changed HTMX swap of #queue-list; rows update without page reload.
List — inline Promote tap Stage 4a modal opens.
List — inline Reject tap Stage 4b modal opens.
List — row body tap Navigates to detail view (md opens right-pane).
Detail — pending Full metadata + spec + Decide section.
Detail — promoted Full metadata + spec + "✓ Promoted on {date}" status chip; no CTAs.
Detail — rejected Full metadata + spec + "✕ Rejected: {reason}" status chip; no CTAs.
Modal Promote — submit pending "Promote" CTA shows "Promoting…" with 16 px spinner; disabled.
Modal Promote — success Modal closes; list view re-renders; toast confirms.
Modal Promote — failure (5xx) Toast "Couldn't promote right now — try again." Modal stays open.
Modal Reject — reason empty on submit Inline red-700 "Please give a reason." Modal stays open.
Modal Reject — submit pending "Reject" CTA shows "Rejecting…" with spinner; disabled.
Modal Reject — success Modal closes; list view re-renders; toast confirms.
Stale state (admin opens an already-decided submission) Detail view renders with status chip; no error toast (this is a legitimate read of historical data).
Race condition (two admins, future V2.x) Server returns 409 if a second admin tries to decide an already-decided submission; toast: "This submission was already reviewed by {other admin}." V2 reality: only Stefan, so this is a defensive guard.

Validation rules (UI surface; canonical server-side)

Rule Inline message
Reject — reason empty "Please give a reason."
Reject — reason > 500 chars "Reason is too long (max 500)."
Promote — admin notes > 500 chars "Notes are too long (max 500)."
Promote / Reject — submission not in pending status (race) "This submission was already reviewed." (The CTA is hidden in this state; defensive on POST.)
Promote — underlying row already shared (race / data drift) "This catalogue entry is already promoted." (Defensive; the schema invariant should prevent this, but a guard is cheap.)

Accessibility

  • List rows have <button> semantics for the inline actions and <a> semantics for the row body. Keyboard tab order: Filter chips → first row body → first row Reject → first row Promote → second row, etc.
  • Filter chips are a <fieldset role="radiogroup" aria-label="Filter by type"> with three <button role="radio"> children. Selected chip carries aria-checked="true".
  • Modal Promote / Reject use role="dialog" aria-modal="true" aria-labelledby="modal-title". Focus moves to the H2 on open; focus trap; ESC dismisses (= Cancel).
  • Required-field marker ("*" on Reason) is decorative; <label> carries aria-required="true".
  • Reject button uses red to signal destructive action; the color pairs with the explicit "Reject" text so color-blind users see the difference. bg-red-700 text-white = 5.94:1 (AA).
  • Status chips ("Promoted" / "Rejected") use icon + text + color — every signal is redundant.
  • Hit targets: every interactive element ≥ 44 × 44 px including the inline Reject / Promote buttons (which take half-row each on sm).
  • Loading affordances ("Promoting…" with spinner) update aria-busy="true" on the button so screen readers announce the in-flight state.

HTMX / progressive-enhancement seams

  • Filter chips: hx-get="/admin/catalogue/queue?kind=racket" hx-trigger="click" hx-target="#queue-list" hx-push-url="true". Without JS: regular <a href> links; full page reload re-renders with the filter applied.
  • Row body tap (md+ pane preview): hx-get="/admin/catalogue/queue/:id?embed=true" hx-target="#detail-pane" hx-push-url="true". Without JS: regular <a href="/admin/catalogue/queue/:id"> navigates to the detail route.
  • Inline Promote button: hx-get="/admin/catalogue/queue/:id/promote" hx-target="#modal-region" hx-trigger="click". Server returns the modal HTML; HTMX swaps it in. Without JS: the button is a regular submit-form that posts to a confirm-promote route, which renders a full page asking for confirmation (extra round-trip; functionally identical).
  • Inline Reject button: same pattern.
  • Modal submit (Promote): <form hx-post="/admin/catalogue/queue/:id/promote" hx-target="body" hx-swap="outerHTML">. Without JS: regular form POST; 303-redirect to list.
  • Modal submit (Reject): same.

The whole flow's fundamental contract is "regular form POSTs". HTMX swaps are optimisations.

i18n affordance

String Type Catalogue key
"Catalogue queue" (page title) {% trans %} admin.catalogue.title
"Pending — oldest first" {% trans %} admin.catalogue.section.pending
"All N" / "Rackets N" / "Strings N" {% trans %} (Babel) admin.catalogue.filter.{all,rackets,strings}
"Decided →" {% trans %} admin.catalogue.archive.link
"Nothing pending" / "All catalogue submissions reviewed. New submissions appear here." {% trans %} admin.catalogue.empty.{title,body}
"Reject" / "Promote" {% trans %} admin.catalogue.action.{reject,promote}
Type tags "Racket" / "String" {% trans %} admin.catalogue.kind.{racket,string}
"submitted by {name}" {% trans %} (Babel) admin.catalogue.submitted_by
"{N} days ago" / "today" / "yesterday" {% trans %} (Babel) admin.catalogue.time.{relative,today,yesterday}
"Submission #{id}" {% trans %} (Babel) admin.catalogue.detail.title
"Specs" / "Submission" / "Decide" {% trans %} admin.catalogue.detail.section.{specs,submission,decide}
Spec field labels (Manufacturer / Model / etc.) {% trans %} admin.catalogue.spec.{manufacturer,model,version,head_size,string_pattern,gauge,serial}
"Stringer's note" {% trans %} admin.catalogue.detail.note_label
"Reject with reason" / "Promote to shared catalogue" {% trans %} admin.catalogue.detail.cta.{reject,promote}
"This makes the racket visible to every stringer." / "This makes the string visible to every stringer." {% trans %} admin.catalogue.detail.cta.context.{racket,string}
"Promote to shared catalogue?" {% trans %} admin.catalogue.modal.promote.title
"{name} will become visible to every stringer." {% trans %} (Babel) admin.catalogue.modal.promote.body
"Notes (optional)" / "Stored on the submission for the audit trail." {% trans %} admin.catalogue.modal.promote.notes.{label,hint}
"Reject this submission?" {% trans %} admin.catalogue.modal.reject.title
"{name} will stay private to {stringer}." {% trans %} (Babel) admin.catalogue.modal.reject.body
"Reason" / "Sarah will see this in her notification." {% trans %} (Babel — for the {stringer} placeholder) admin.catalogue.modal.reject.reason.{label,hint}
"Cancel" {% trans %} common.cancel (shared)
Toast — "Promoted {name} to the shared catalogue." {% trans %} (Babel) admin.catalogue.toast.promoted
Toast — "Rejected. {name} will be notified." {% trans %} (Babel) admin.catalogue.toast.rejected
Validation messages {% trans %} admin.catalogue.validation.{reason_empty,reason_long,notes_long,already_decided,already_shared}

Iris's DE pass follows after merge.

DE width budget (designer note)

  • "Catalogue queue" → "Katalog-Warteschlange" (~1.4×) — fits header.
  • "Reject" → "Ablehnen" (similar).
  • "Promote" → "Freigeben" (~1.0×) — fits.
  • "Promote to shared catalogue" → "In den geteilten Katalog freigeben" (~1.6×) — wraps to two lines on the modal CTA; reserve room.
  • "Stringer's note" → "Notiz des Stringers" (~1.3×).
  • "All catalogue submissions reviewed. New submissions appear here." → "Alle Katalog-Einreichungen geprüft. Neue Einreichungen erscheinen hier." (similar) — fits.
  • Spec labels translate cleanly (existing receipt-PDF DE pass has these terms).

Cross-references

Open questions for Stefan (with proposed defaults)

  1. Inline Promote / Reject buttons on the list view, in addition to the detail view CTAs. Proposed default: yes — inline buttons on each row. Stefan's "skim and decide" workflow on a queue of trivial decisions (almost every submission is "obviously promote — Wilson is a real brand") wants the one-tap path. The detail view is the deeper-dive option. Alternative: list view is row-tap-to-detail only; decisions happen exclusively in the detail view. Adds friction; rejected. Stefan to confirm the inline-buttons stance.

  2. Date display — relative ("4 days ago") + absolute ("2026-04-28") side by side, or one or the other? Proposed default: both — relative on top, absolute below in muted text. Relative gives at-a-glance freshness; absolute gives unambiguous reference for Stefan's "I'll get back to that one Monday" mental model. Alternative: relative only (compact); absolute only (precise but harder to skim). Mira leans both.

  3. Admin notes on Promote — visible to the submitter, or admin-only? Proposed default: admin-only by default; the notes column on CatalogueSubmission stores them but the catalogue_submission_decided notification body does NOT include them. The Reject reason IS shown to the submitter (because rejection requires explanation); the Promote notes are admin internal record-keeping ("checked spec sheet, matched Wilson's site"). Alternative: include Promote notes in the notification too. Stefan to confirm; if asymmetric, document the rule on the submission row.

  4. CatalogueSubmission.notes field — append admin notes to the existing notes, or use a separate field? Proposed default: append to the same field, with a [admin]: prefix per the SQL spec above. Schema simpler (one notes column for the audit trail); the prefix makes provenance clear on display. Alternative: split into submitter_notes + admin_notes. This is a Pax-A schema question — I'm proposing the simpler stance; Pax-A may have a different preference. Cross-link with his MR when it lands.

  5. Filter chip default — "All" vs. "Rackets" vs. last-used. Proposed default: "All". Most-frequent triage stance. Alternative: remember the last-used filter via session cookie. Adds state; for V2's volume, the All default is fine. Stefan to confirm.

  6. Decided archive — paginate, or hide entirely until requested? Proposed default: list link "Decided →" at the bottom of the pending list; page renders 25-per-page paginated archive on demand. Surfaces the audit trail without polluting the pending workflow. Alternative: hide entirely (admin uses DB queries if they need the audit trail). Mira leans light archive over no archive.

  7. Submission landing notification per admin — fan-out invariants. Confirmed via ADR-0005 § Catalogue moderation hooks: one row per (submission, admin) pair. V2 reality is one admin = one row per submission. Flagged for visibility; nothing to flip.

  8. Type tag iconography — lucide:circle-dot for racket and lucide:zap for string, or different icons? Proposed default: emoji 🎾 for racket and a custom string-shape icon (or lucide:minus rotated 90°) for string, until we have proper SVG iconography. The emoji is more recognisable in the V2 timeline; the SVG upgrade is a Round-4 polish. Alternative: text-only type tag with no icon (cleaner; less scannable). Mira leans icon + text.

  9. Detail view back-link behaviour. Proposed default: back arrow returns to list view, preserving filter state. If Stefan was filtering "Rackets only" before tapping in, he returns to the same filtered list. Alternative: always returns to "All" filter (simpler; loses Stefan's place). Mira leans preserve-state.

  10. Reject reason — soft constraints on quality (e.g. minimum 10 chars). Proposed default: minimum 1 char (just non-empty). Stefan's reasons might genuinely be one word ("duplicate") and that's a fine reason. A minimum-quality regex would frustrate. Alternative: minimum 10 chars to encourage explanation. Stefan to confirm; the lenient stance is the productivity-first pick.

  11. Stale-state handling — admin opens a queue link from a notification but the submission was already decided by another admin. Proposed default: render the detail view in its decided state with no error. This is legitimate read access to historical data. Alternative: redirect with a "this was already reviewed" toast. Mira leans silent (the page itself shows the decided state; no toast needed).

  12. Race condition between admin viewing the queue and another admin deciding (V2.x). Proposed default: optimistic — first writer wins; second writer gets a 409 → toast "This submission was already reviewed by {admin_name}." Cheap to implement; covers the rare race. Alternative: pessimistic locking via a reviewing_by_admin_id heartbeat (over-engineered for V2). Stefan to confirm; defer to V2.x if more than one admin ever exists.

  13. Underlying row visibility rendering on the detail view — do we show "Currently: private to Sarah Reber" before Promote, so Stefan knows the starting state? Proposed default: yes — a "Current visibility" line above the Decide section. Tiny clarity win; helps the rare "wait, isn't this already shared?" moment. Alternative: omit (the spec list already implies private). Mira leans show-it.

  14. Promote irreversibility — should the modal warn that promotion is permanent (admin can flip visibility back via DB but there is no UI for it in V2)? Proposed default: no warning. Promote is trivially reversible by re-running the SQL; the warning would scare Stefan into hesitating on routine promotes. The honest model is "this is a normal admin action." Alternative: a "this cannot easily be undone" hint. Mira leans no warning; Stefan to confirm whether the irreversibility is worth surfacing.

  15. Notification body content for Reject — does the stringer see the rejected reason verbatim, or a summary? Proposed default: verbatim. The reason is meant for the stringer; surfacing it as-is matches the explicit-feedback contract. Alternative: a generic "rejected — see notification body for details" pattern with the verbatim reason as a deeper detail. Mira leans verbatim.

  16. Admin-side Decide — the route. /admin/catalogue/queue/:id/promote and .../reject as separate POST endpoints, or one /decide endpoint with a decision field? Proposed default: two separate endpoints. Clearer URL semantics; clearer audit trail in server logs. Alternative: single endpoint with a body field. Pax-A's call ultimately; flagged for visibility.