Skip to content

R2-0 Admin Workflow Teardown

Status: Active Last reviewed: 2026-07-10 Slice: R2-0 Admin workflow teardown

Source-grounded inventory of what a club admin/총무/session host does today, which screens and queries serve each job, and which data inputs are missing — recorded BEFORE any R2 UI work starts, per the slice contract. Repo state cited at commit ca3b0eab. All claims traced to file:line; the four load-bearing negative claims (no club-wide session-payment read, no club-wide guest-application read, orphaned member-report mutation, no closeout signal) were independently re-verified by grep before this doc was written.

Use this with:


1. Entry point: the admin console

Every admin job routes through one screen: AdminTab (packages/features/clubs/src/club-detail/admin-tab.tsx:29-146), rendered inside club-detail-screen.tsx's admin tab. It reads useClubRole(clubId) and shows rows gated per-permission:

  • canManageMembers → 멤버 row (admin-tab.tsx:57-68)
  • canManageDues → 회비 row (admin-tab.tsx:71-79)
  • canManageClub → 출석, 리그/챌린지, 코트, 설정 rows (admin-tab.tsx:82-89, 109-136)
  • entitlements.growthAnalyticsEnabled && canManageClub → 분석 row (admin-tab.tsx:92-99)
  • canManageSchedule → 일정 관리 row (admin-tab.tsx:102-107)

Two rows carry live attention badges, fetched directly in AdminTab: useClubJoinRequests(clubId,'pending') and useOverdueDues(clubId) (admin-tab.tsx:44-49). No other row has a badge — dues and club join-requests are the only jobs with an existing attention count; attendance, sessions, session payments/holds, and guest applications have none.

Role source: useClubRole(clubId) (packages/app/src/presentation/hooks/use-club-role.ts:55-124) derives permissions client-side from hasPermission(club, role, permission) (domain/entities/club.entity.ts) over the member's row role (owner/admin/match_director/member) and the club's roleConfigs (falls back to DEFAULT_ROLE_CONFIGS). This is the canonical gate referenced below as canManageClub/canManageMembers/canManageDues/canManageSchedule.

2. Jobs → Surfaces map

2.1 Session creation / closeout

JobScreen(s) / routeHooks / RPCsRole gate
Create sessioncreate-session wizard, route apps/mobile/app/create-session.tsxuseCreateSessionClub-context create action; pickup-host path separate
Edit sessionedit-session-screen.tsx (packages/features/sessions/src)useUpdateSessionisHost || canManageSchedule (session-detail-screen.tsx:429-430)
Cancel session (host)Session detail actions menuuseCancelSessionAsHost, useDeleteSessionisHost
Start / end sessionSession detail, match-board-screen.tsxuseStartSession, useEndSession (use-end-session.ts:12-21)isHost || canManageSchedule (session-detail-screen.tsx:430)
Post-session closeout nudgePostSessionStepsSheet (packages/features/sessions/src/post-session-steps-sheet.tsx:16-100), triggered from session-detail-screen.tsx:1597 and match-board-screen.tsx after end-sessionRoutes to routes.sessionAttendance(sessionId) (canManageClub) and routes.sessionPayments(sessionId) (canManageDues); rows conditionally rendered per gate (post-session-steps-sheet.tsx:65-80)canManageClub / canManageDues
Session listing (admin)ClubSessionsScreen (packages/features/clubs/src/club-sessions-screen.tsx:1-13), route routes.clubSessions(clubId), from admin-tab 일정 관리 rowuseClubSessions(clubId, statusFilter); useCompletedClubSessions for 완료 panecanManageSchedule opens the row; screen visible to any member

Key finding: "sessions needing closeout" (attendance not confirmed and/or payments not settled) has no derived signal anywhere. ClubSessionsScreen's 완료 pane lists completed sessions but does not annotate which still have unconfirmed attendance or unsettled payments. The admin must open each completed session and check attendance + payments screens separately.

An existing deriveSessionCloseout / SessionCloseoutSummary (packages/app/src/presentation/utils/session-closeout.ts:1-83) is not this concept: it classifies match-score completeness/disputes (no_matches, matches_incomplete, score_dispute, score_review, complete) for the viewer/participant, consumed only by useTodayRecapSessions (use-today-recap-sessions.ts:58-170) on the Home 오늘 recap. Naming collision to avoid in R2-1 — do not reuse or shadow this name for admin closeout.

2.2 RSVP / waitlist management

JobScreenHooksRole gate
View RSVP rostersession-detail-screen.tsxuseSessionRsvps(sessionId); bulk sibling useSessionsRsvps(sessionIds) exists (use-rsvps.ts:76-103)Any member; host sees management actions
RSVP self-serviceSession detailuseRsvpMember
Guest application approve/decline (open-invite sessions)Session detail 신청 관리 panel, gated isHost && session.publicInviteEnabled && pendingApplicationCount > 0 (session-detail-screen.tsx:1300)useSessionApplications(sessionId); useDecideGuestApplication (RPC decide_guest_application, 00215)isHost (session creator), not club role

Protocol A gap: RSVPs have per-session + bulk hooks — no gap. Session guest applications do not: guest-application.supabase.ts:19-106 exposes findById, findBySession, findByApplicant, findBySessionAndApplicant, create, updateStatus, decideno findByClub, and guestApplicationKeys (query-keys.ts:243-249) has no byClub shape. A club admin with multiple concurrent open-invite sessions has no single surface answering "which sessions have pending guest applications."

2.3 Fee confirmation (송금 완료 → paid, per-session 정산)

Screen: SessionPaymentsScreen (packages/features/sessions/src/session-payments-screen.tsx:159-517), route routes.sessionPayments(sessionId) (routes.ts:191).

  • Gate: canManageDues || isSessionHost (session-payments-screen.tsx:357-371).
  • Model: SessionPayment (session.entity.ts:387-403) — status: 'pending'|'paid'|'waived', submittedAt: Date|null (송금 완료 marker), refundState: 'none'|'pending'|'acknowledged'.
  • 송금 완료 UI: status==='pending' && submittedAt!=null renders the warning badge (session-payments-screen.tsx:116-118) and sorts first (lines 240-250).
  • Mutations: useMarkPaymentPaid, useMarkPaymentWaived, useGeneratePayments (queries/use-session-payments.ts:92-104).
  • Query: useSessionPayments(sessionId) is the only list read; useMySessionPayments(userId, sessionIds?) is the viewer's own cards.

Protocol A gap (the largest single missing link for R2-1):session-payment.supabase.ts:12-127 exposes findBySession, findByUser, createBatch, markPaid, markWaived, attest, devMockConfirm, acknowledgeRefundno club-wide read. sessionPaymentKeys (query-keys.ts:355-360) has bySession, byUser, byUserSessionsno byClub. Nothing answers "who still owes money across our sessions" or "which submitted transfers await confirmation" without opening each session's 정산 screen.

2.4 Dues (club-wide monthly membership fee — distinct from §2.3)

Screen: ClubDuesScreen (packages/features/clubs/src/club-dues-screen.tsx:73-434), route routes.clubDues(clubId).

  • Gate: canManageDues (club-dues-screen.tsx:78); non-admins get a read-only view of their own dues.
  • Queries: useClubDues(clubId, year, month), useMyDues, useOverdueDues (clubId), useDuesSummary (use-dues.ts:16-68) — club-scoped already.
  • Mutations: useUpdateDuesStatus, useGenerateDues (club-dues-screen.tsx:96-97, 176-274).
  • Adapter: dues.supabase.ts:16-125findByClub, findByMember, findOverdue, createBatch, updateStatus, getClubSummary.

Best-covered job of the set: club-scoped read, club-scoped overdue signal, and the AdminTab badge already wired.

2.5 Attendance / no-show strikes

Screen: ClubAttendanceScreen (packages/features/clubs/src/club-attendance-screen.tsx:260-336), route routes.clubAttendance(clubId), gated canManageClub (club-attendance-screen.tsx:265, 302-309).

  • Query: useClubAttendanceSummary(clubId) (use-club-attendance-summary.ts:95-116) — club-wide already, composing attendanceRecords.findByClub(clubId) with members/profiles, deriving per-member attended/noShows/lateCancel/freeCancel/excused/rate/ strikesInWindow/isFlagged (flag: strikesInWindow >= 3 over a rolling 20-record window, lines 19-90).
  • Excuse flow: AttendanceExcuseModaluseMemberStrikeRecords(userId, clubId) + per-record useExcuseAttendance (club-attendance-screen.tsx:108-176).
  • Per-session confirm: SessionAttendanceScreen (session-attendance-screen.tsx:1-9, 120, 188) — gated canManageClub, useSessionAttendance(sessionId) + useConfirmAttendance (offline-queueable, use-confirm-attendance.ts:1-118).

Second-best-covered job. Note isFlagged is client-derived inside the hook, not a server aggregate.

2.6 Club join requests / member roles

  • Join-request review: useClubJoinRequests(clubId, status) (use-club-join-requests.ts:12-23, RLS-restricted) drives the AdminTab badge; decide via useDecideJoinRequest (use-decide-join-request.ts:23-38). Club-scoped already.
  • Roster/roles: ClubMembersScreen (club-members-screen.tsx:257-460), MemberActionModalMemberRoleModal (lines 181-256), useUpdateMemberRole, useRemoveMember, gated canManageMembers (line 266).

2.7 Reports / corrections

  • Orphaned write, no read: useSubmitReport (use-submit-report.ts:1-16) creates a member_reports row ("for 총무 to report a member"). Zero product call sites (grep across packages/features and apps, excluding build artifacts). No query hook reads memberReportKeys.byClub anywhere — the invalidation target has no reader.
  • Domain model anticipates a resolution workflow: MemberReport (trust.entity.ts:152-163) has resolved, resolvedBy, resolvedAt — no UI reads or resolves reports.
  • Unrelated system, not an admin surface: useReportContent/useBlockUser (use-safety-mutations.ts:1-52) backed by content_reports — DM/moderation safety reporting, likewise reviewerless in-app.

The one enumerated job with zero read/review surface at any scope.

3. Query inventory — Protocol A summary

DomainPer-item hookClub-wide siblingStatus
Dues (monthly)useMyDuesuseClubDues, useOverdueDuesOK
AttendanceuseSessionAttendance, useMemberAttendanceuseClubAttendanceSummaryOK
Club join requestsuseClubJoinRequests(clubId, status)OK
RSVPsuseSessionRsvpsuseSessionsRsvps(sessionIds[]) (composable from useClubSessions)OK
Session payments (정산 holds)useSessionPayments(sessionId)none — no findByClub, no byClub keyGAP
Session guest applicationsuseSessionApplications(sessionId)none — no findByClub, no byClub keyGAP
Member reportsuseSubmitReport (write-only)none — no read hook at any scopeGAP

N-screens-for-one-question cases confirmed:

  • "Who hasn't paid across our sessions this week?" → open every session's 정산 screen; no club aggregate.
  • "Which 송금 완료 transfers are waiting on me?" → same per-session constraint; the submittedAt badge only surfaces inside an already-opened session.
  • "Which sessions have pending guest applications?" → open every open-invite session detail.
  • "What reports were filed against members?" → no screen exists.
  • "Which completed sessions still need closeout?" → no derived signal; requires cross-referencing useClubSessions(clubId, ['completed']) against per-session attendance and per-session payments, which are not joined today.
GapEvidenceR2-1 item affected
session_payments.submitted_at (송금 완료 hold) has no club-wide readsession-payment.supabase.ts:9,12-127Unpaid holds / submitted transfers
guest_applications has no club-wide readguest-application.supabase.ts:19-106; query-keys.ts:243-249Join/guest applications
member_reports has a write mutation + domain schema, zero read hook, zero UI call siteuse-submit-report.ts:1-16; trust.entity.ts:152-163Reports
No cross-session closeout signal (attendance confirmed? payments settled?) for completed sessionsclub-sessions-screen.tsx:1-13; session-closeout.ts is match-score-only, participant-scopedSessions needing closeout
get_home_signals / HomeSignals is entirely player-perspective (pendingScoreVerificationCount, pendingFeedbackCount, own overdueDuesCount, …) — no admin countershome-signals.entity.ts:21-49Cross-club admin exceptions (feeds R2-3; R2-1 must not assume this RPC covers admin cases)
useHomeData's AggregateRole ('guest'/'member'/'admin') detects admin status across clubs but composes no admin-exception datause-home-data.ts:1-45, 93Role-gating foundation exists; data aggregation does not

5. R2-1 read-model feasibility, item by item

R2-1 required answerExists todayFeasibility
Upcoming sessionsuseClubSessions(clubId, ['open','locked']); cross-club via useClubUpcomingSessionsExists, composable; no new backend
Unpaid holdssession_payments.status='pending' column existsRead path missing — needs club-scoped query/RPC
Submitted transferssession_payments.submitted_at IS NOT NULL AND status='pending'Same missing read — same root cause as above
Overdue duesuseOverdueDues(clubId) via dues.findOverdueExists, already badge-wired
Attendance risksuseClubAttendanceSummary(clubId), isFlagged thresholdExists (client-derived; could move server-side)
Join/guest applicationsClub join requests: exists. Session guest applications: no club-wide readHalf exists
Reportsmember_reports table + domain type onlyNo read of any kind — new hook/RPC + new surface
Sessions needing closeoutNothing joins sessions + attendance + paymentsDoes not exist — new RPC/view, or client composition once the payment club-read exists. Avoid the deriveSessionCloseout naming collision

Bottom line for R2-1: 3 of 8 answers have club-scoped reads today (upcoming sessions, overdue dues, attendance risks); 1 is half-covered (applications); 4 have no club-wide read path (unpaid holds, submitted transfers, reports, closeout) — and two of those share one root cause: session_payments has no club-scoped read shape at all. A single club-scoped session-payments read plus a closeout join closes half the missing surface.

Markdown remains the source of truth. Run yarn docs:check before handoff.