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:
- Roadmap Implementation Plan for R2 slice status and verification ladders.
- R1 Activation Source Pack as the format precedent.
- App Functionality Map for the broader surface map.
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
| Job | Screen(s) / route | Hooks / RPCs | Role gate |
|---|---|---|---|
| Create session | create-session wizard, route apps/mobile/app/create-session.tsx | useCreateSession | Club-context create action; pickup-host path separate |
| Edit session | edit-session-screen.tsx (packages/features/sessions/src) | useUpdateSession | isHost || canManageSchedule (session-detail-screen.tsx:429-430) |
| Cancel session (host) | Session detail actions menu | useCancelSessionAsHost, useDeleteSession | isHost |
| Start / end session | Session detail, match-board-screen.tsx | useStartSession, useEndSession (use-end-session.ts:12-21) | isHost || canManageSchedule (session-detail-screen.tsx:430) |
| Post-session closeout nudge | PostSessionStepsSheet (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-session | Routes 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 일정 관리 row | useClubSessions(clubId, statusFilter); useCompletedClubSessions for 완료 pane | canManageSchedule 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
| Job | Screen | Hooks | Role gate |
|---|---|---|---|
| View RSVP roster | session-detail-screen.tsx | useSessionRsvps(sessionId); bulk sibling useSessionsRsvps(sessionIds) exists (use-rsvps.ts:76-103) | Any member; host sees management actions |
| RSVP self-service | Session detail | useRsvp | Member |
| 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, decide — no 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!=nullrenders 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, acknowledgeRefund — no club-wide read. sessionPaymentKeys (query-keys.ts:355-360) has bySession, byUser, byUserSessions — no 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-125—findByClub,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, composingattendanceRecords.findByClub(clubId)with members/profiles, deriving per-memberattended/noShows/lateCancel/freeCancel/excused/rate/ strikesInWindow/isFlagged(flag:strikesInWindow >= 3over a rolling 20-record window, lines 19-90). - Excuse flow:
AttendanceExcuseModal→useMemberStrikeRecords(userId, clubId)+ per-recorduseExcuseAttendance(club-attendance-screen.tsx:108-176). - Per-session confirm:
SessionAttendanceScreen(session-attendance-screen.tsx:1-9, 120, 188) — gatedcanManageClub,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 theAdminTabbadge; decide viauseDecideJoinRequest(use-decide-join-request.ts:23-38). Club-scoped already. - Roster/roles:
ClubMembersScreen(club-members-screen.tsx:257-460),MemberActionModal→MemberRoleModal(lines 181-256),useUpdateMemberRole,useRemoveMember, gatedcanManageMembers(line 266).
2.7 Reports / corrections
- Orphaned write, no read:
useSubmitReport(use-submit-report.ts:1-16) creates amember_reportsrow ("for 총무 to report a member"). Zero product call sites (grep acrosspackages/featuresandapps, excluding build artifacts). No query hook readsmemberReportKeys.byClubanywhere — the invalidation target has no reader. - Domain model anticipates a resolution workflow:
MemberReport(trust.entity.ts:152-163) hasresolved,resolvedBy,resolvedAt— no UI reads or resolves reports. - Unrelated system, not an admin surface:
useReportContent/useBlockUser(use-safety-mutations.ts:1-52) backed bycontent_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
| Domain | Per-item hook | Club-wide sibling | Status |
|---|---|---|---|
| Dues (monthly) | useMyDues | useClubDues, useOverdueDues | OK |
| Attendance | useSessionAttendance, useMemberAttendance | useClubAttendanceSummary | OK |
| Club join requests | — | useClubJoinRequests(clubId, status) | OK |
| RSVPs | useSessionRsvps | useSessionsRsvps(sessionIds[]) (composable from useClubSessions) | OK |
| Session payments (정산 holds) | useSessionPayments(sessionId) | none — no findByClub, no byClub key | GAP |
| Session guest applications | useSessionApplications(sessionId) | none — no findByClub, no byClub key | GAP |
| Member reports | useSubmitReport (write-only) | none — no read hook at any scope | GAP |
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
submittedAtbadge 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.
4. Missing links — data with no admin surface
| Gap | Evidence | R2-1 item affected |
|---|---|---|
session_payments.submitted_at (송금 완료 hold) has no club-wide read | session-payment.supabase.ts:9,12-127 | Unpaid holds / submitted transfers |
guest_applications has no club-wide read | guest-application.supabase.ts:19-106; query-keys.ts:243-249 | Join/guest applications |
member_reports has a write mutation + domain schema, zero read hook, zero UI call site | use-submit-report.ts:1-16; trust.entity.ts:152-163 | Reports |
| No cross-session closeout signal (attendance confirmed? payments settled?) for completed sessions | club-sessions-screen.tsx:1-13; session-closeout.ts is match-score-only, participant-scoped | Sessions needing closeout |
get_home_signals / HomeSignals is entirely player-perspective (pendingScoreVerificationCount, pendingFeedbackCount, own overdueDuesCount, …) — no admin counters | home-signals.entity.ts:21-49 | Cross-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 data | use-home-data.ts:1-45, 93 | Role-gating foundation exists; data aggregation does not |
5. R2-1 read-model feasibility, item by item
| R2-1 required answer | Exists today | Feasibility |
|---|---|---|
| Upcoming sessions | useClubSessions(clubId, ['open','locked']); cross-club via useClubUpcomingSessions | Exists, composable; no new backend |
| Unpaid holds | session_payments.status='pending' column exists | Read path missing — needs club-scoped query/RPC |
| Submitted transfers | session_payments.submitted_at IS NOT NULL AND status='pending' | Same missing read — same root cause as above |
| Overdue dues | useOverdueDues(clubId) via dues.findOverdue | Exists, already badge-wired |
| Attendance risks | useClubAttendanceSummary(clubId), isFlagged threshold | Exists (client-derived; could move server-side) |
| Join/guest applications | Club join requests: exists. Session guest applications: no club-wide read | Half exists |
| Reports | member_reports table + domain type only | No read of any kind — new hook/RPC + new surface |
| Sessions needing closeout | Nothing joins sessions + attendance + payments | Does 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.