Skip to content

Admin ​

Admins (profile.role === 'admin') and staff (profile.role === 'staff') share the admin shell at /admin/*. Staff is a read-mostly subset; everything that mutates catalog or user data is admin-only. The shells (AppShell / AdminShell) are mirror images — same visual language, separate nav, with a toggle to swap.

Routes ​

Read-only event surfaces — gated by StaffOrAdminRoute (staff + admin):

RoutePagePurpose
/admin—Redirects to /admin/logistics, the day board
/admin/homeDashboardPageShared home page (divers see the same one at /dashboard) — welcome banner, featured trips, and the shortcut tiles, including the admin-only dive-site map. Where the header logo points
/admin/eventsAdminEventsPageMonth view of every event with registration counts
/admin/events/:idAdminEventDetailPageRegistrants, memos, status controls for one event
/admin/events/:id/gear-mapAdminGearMapPagePer-registrant gear/sizing checklist for the event
/admin/accountingAdminAccountingPageRevenue by staff + document exports. Staff get the revenue tab only, scoped to themselves — see Revenue by staff

Write/manage routes — gated by AdminRoute (admin only):

RoutePagePurpose
/admin/newAdminManagePageCatalog landing — links to event/room/addon/travel/price editors
/admin/new/eventAdminNewEventPageCreate a new dive or course
/admin/events/:id/editAdminEditEventPageEdit event details
/admin/roomsAdminRoomsPageManage rooms rows
/admin/addonsAdminAddonsPageManage addons rows
/admin/travelAdminTravelPageManage Trip Templates (trip_templates rows)
/admin/pricesAdminPricesPageManage prices rows
/admin/usersAdminUsersPageSearchable diver directory with full profile cards
/admin/dutyAdminDutyPageAssign staff/admin to events; fires push to assignee
/admin/notificationsAdminNotificationsPageCompose + send a one-off Web-Push broadcast to all subscribed devices
/admin/backupAdminBackupPageDownload the whole database as one CSV-per-table ZIP — see Database backup
/admin/shutdownAdminShutdownPageGuided shut-down: readiness checks, exports, a farewell push, and the ordered switch-off list — see Closing down
/admin/packagesAdminPackagesPagePackages referral network: curate travel packages abroad + the referral/kickback pipeline (see packages.md)
/admin/scheduled-tripsAdminScheduledTripsPageScheduled Trips: the shop's own dated trips (price + catalog add-ons/rooms) divers register for; Trips + Registrations tabs
/admin/trusted-partnersAdminTrustedPartnersPageTrusted Partners: CRUD the unified partner registry (incl. private contact_email) that backs both the diver directory and Packages (see trusted-partners.md)
/admin/destinationsAdminDestinationsPageManage travel_destinations rows (dive sites / regions)
/admin/vehiclesAdminVehiclesPageThe shop's transport fleet: seats per vehicle, active/retired
/admin/gear-sizingAdminGearSizingPageWetsuit / BCD / fin sizing charts that drive the logistics fit lookup
/admin/waiversAdminWaiversPageShop-authored waivers: text or uploaded PDF, per-event attach, e-signed by divers
/admin/cancellation-policiesAdminCancellationPoliciesPageShop-authored cancellation policies, attached per event
/admin/discountsAdminDiscountsPageTwo halves of one subject: the requests waiting on a decision, and the shop's discount catalog. Approving is the only thing that takes money off a booking — see payments.md § Discounts
/admin/payment-methodsAdminPaymentMethodsPageHow divers can pay: the method list, each one's bank account / payment link / surcharge — see payments.md § Payment methods
/admin/contactAdminContactPageHow divers reach the shop: the company email / phone / address / map link, and the ordered list of Contact-tab buttons (LINE, WhatsApp, Telegram, a phone number, …). Replaces what used to be siteConfig.contact
/admin/shop-profileAdminShopProfilePageThe shop's own identity: upload and size its logo, pick the training agency the app names certifications in, and record the currency and app language. Logo and agency apply at once; currency and language are compiled in, so the page says so and names the fundive.config.ts lines to change
/admin/cert-equivalenceAdminCertEquivalencePageEvery agency's certification ladder lined up against one agency's words — the chart the app reads a diver's certification through
/admin/termsAdminTermsPageThe shop's Terms of Use: one Markdown document, versioned. Ticking material change bumps the version, and every diver re-accepts on their next visit
/admin/applicationsAdminApplicationsPageSuspended accounts: reinstate one you put on hold, reopen one you closed (closing never deleted it), or close an active account
/admin/logisticsAdminLogisticsPageDay-of board in four tabs: the per-guest gear checklist, rides, people and payments — see Gear
/admin/dashboardAdminDashboardPageRevenue, bookings, fill rate
/admin/historyAdminHistoryPageSeason-over-season comparison against a weather baseline
/admin/accountingAdminAccountingPageFiscal-year transaction CSV export

All routes are also wrapped by ProtectedRoute — see authentication.md.

Event detail ​

/admin/events/:id shows one dive or course. The page has:

  • Registrants — expandable cards per booking. Expanded view includes the diver's profile summary (cert, contact, sizing, medical), the full booking details (gear/room/addons), and the per-booking payments ledger.
  • Per-registrant actions:
    • Change bookings.status to any of pending / confirmed / waitlisted / cancelled.

    • Promoting off the waitlist emails the diver. waitlisted → confirmed is the only status change that notifies, via the notify-booking-confirmed edge function. Every other status the shop sets follows something the diver just did — they registered, they paid, they asked to cancel — but someone on the waitlist is waiting on a stranger to drop out, so without a message they find out by opening the app on the off chance. The predicate is isWaitlistPromotion in src/lib/booking-status.ts; the send is fire-and-forget, so a bounced email surfaces as a toast telling the admin to call them, and never rolls back the seat.

      This is the manual path only. When a cancellation frees a spot, the cron worker offers it to the next person in line and notify-waitlist-offer emails them a deadline to accept — that email asks for an action, this one doesn't, because the shop has already handed the seat over.

    • Mark deposit paid — shown on pending bookings. A pure status shortcut: confirms the booking (deposit received off-app) and does not record a payment or change the owed/paid balance. Record the actual amount received via the payments ledger; the owed/paid figures only ever move from recorded payments + amendments.

    • Approve refund — visible when refund_requested_at is set. Sets status = 'cancelled'. The actual refund transfer happens out-of-band; record it by inserting a payments row with status = 'refunded'.

  • Event memos — see below.

Boat manifest export ​

The Export diver info button opens a modal for the vessel details (boat name, registration, footer notes — pre-filled and remembered in localStorage since the chartered boat varies per trip), then calls the export-event-divers edge function. The function emails an .xlsx matching the Taiwanese recreational-fishing-vessel passenger form (娛樂漁業漁船出海人員名冊) to the shop inbox, BCCing the requesting admin.

  • Rows: every pending or confirmed booking (cancelled and waitlisted divers are excluded), followed by the staff on duty for the event (the duties rows — instructors / guides / support). Staff are deduped by person (a course duty is one row per day) and anyone already listed as a booked diver is skipped, so no one appears twice.
  • Columns are the official Chinese form (編號 / 姓名 / 身分證字號 / 出生 年月日 / 性別 / 潛水執照等級 / 潛水總支數 / 國家 / 備註). The sheet is Unicode, so no font embedding is needed. The 姓名 column is the diver's legal name, exactly as on their ID, since the manifest must match identity documents.
  • A staff member's role is written into the 備註 (remark) column, localized to Chinese (instructor→教練, guide→導潛, support→支援); booked divers leave 備註 blank.
  • Gender and nationality are best-effort localized to Chinese (male→男, American→美國); unrecognized free-text values pass through untouched. See _shared/event-divers-manifest.ts (pure, unit-tested) for the mappings and _shared/event-divers-xlsx.ts for the SheetJS serialization.
  • The function returns { diver_count, staff_count }; the toast shows both (e.g. "7 divers + 2 staff").

Divers and non-divers ​

Not everyone on a day's board is diving. EFR and CPR students, an equipment class and a beach BBQ all put people on the roster who never get wet, and the shop's insurer asks for exactly that split: of the people you signed up that day, how many were in the water?

The kind cannot answer it — an EFR course and an Open Water course are both kind='course' — so the event form asks the admin instead (Participants → "Nobody goes in the water at this event", writing events.enters_water). eventEntersWater() in src/lib/participants.ts is the only place that answer is read: it ANDs the column with entersTheWater(kind), so an adventure stays dry whatever the column says, and a snapshot captured before the column existed degrades to "diving" rather than emptying the diver list.

The board's People section counts both at the top — "2 events · 20 divers · 3 non-divers" — and lists each event's guests under it, a dry event's on orange chips. The count is a question about the person, not the booking: somebody who takes the morning EFR class and dives in the afternoon is one diver, because any in-water booking wins. Each dry event also carries a "Dry — nobody in the water" badge on its banner and counts non-divers rather than divers there.

Transport: runs, seats, riders ​

Everything on the Logistics day view (/admin/logistics) is planned per run — the set of events that travel together. Two dives at the same site share a van; a course at the pool and a dive at Bat Cave cannot, and no field in the schema can tell them apart. So the shop states it: the Shared transport picker in the board's Rides section writes event_ride_groups rows (one per (ride_day, event_id); events sharing a group_id ride together). An event with no row rides alone.

Given the runs, every count follows, and each of these is counted once:

  • Seats — passenger_seats over the run's distinct cars. A van assigned to both events of a run is one van with one set of seats.
  • Riders — the divers who asked for a ride plus every on-duty staff member. There is no driver concept: staff take ordinary seats (docs-worthy consequence: a 7-seat van with 2 staff aboard has 5 seats left for divers). Someone on duty who is also booked as a diver is one body.
  • Nothing is pooled across runs. Two runs are planned separately, so slack in one never covers a shortfall in the other.

The board flags what can't physically happen: one car taken by two runs, or one person (staff or diver) expected on two runs at once.

The same arithmetic backs the diver-facing "N ride seats left" on the registration form, via the event_ride_seats RPC — capacity = seats − staff, claimed = distinct divers holding a ride anywhere on the run.

A ride is never a question about the event's kind. event_vehicles accepts any event, event_ride_tally matches ride days against course_days as explicitly as against a date envelope, and the shop drives Open Water students out to their shore days. Both register forms therefore fetch the tally for every kind. They did not always: courses used to skip the lookup, so a full car showed no warning and the DB trigger below waitlisted the diver with nothing on screen having said so.

It is a question about the event. A dry course — EFR, an Equipment specialty, an O2 provider course — moves nobody, and asking a diver registering for one whether they want a ride to the site is a question with no right answer. The Transport not needed for this event switch in the vehicle section (EventCarAssignment on the edit page and the event's Transportation tab, CreateEventVehiclePicker on the new-event form) writes events.has_transport. With it off, both register forms render no transport fieldset and never fetch the seat tally, the step stops waiting for an answer, and create-registration forces details.transportation to false rather than trusting a request that answers a question the form never put. It is admin-set rather than inferred because "not strictly a diving course" is not a fact the kind or the title carries — and the last helper that guessed transport from the kind was wrong in both directions.

Turning it off leaves any cars already assigned to the event in place; the section says how many are still there, so nothing is silently orphaned and switching back on restores what was picked.

A ride requested when the run is full is not refused: the booking goes through flagged details.ride_waitlisted, and every admin gets an "add a car" notification. That flag is computed by the database on insert and on any details edit (bookings_set_ride_waitlist), not taken from the client — a forged false would otherwise hide a full run from the shop, and a stray true would page every admin about a ride nobody asked for. A diver who already holds a ride somewhere on the run keeps it; a second booking on the same run is the same body in the same seat. A run with no car assigned at all counts as "capacity not set up yet", not "full": the booking goes through unflagged so a shop can take reservations and plan the van later (canRequestRide in src/lib/event-vehicles.ts is the matching client-side default — a shop that would rather block early flips it there and drops the branch in the trigger). The planner itself is pure and unit-tested in src/lib/vehicle-planning.ts (planFleet for one run, planRuns for a day); grouping lives in src/lib/ride-groups.ts.

Gear: the pack checklist ​

The day board opens on Gear, one of four sections picked from a row of big tabs (Gear, Rides, People, Payments) so only one job is on screen at a time. Each tab carries its own count: pieces packed, riders, heads, balances due.

Gear is a checklist of guests, not of items. Every guest renting anything gets a card under the event they are booked on, and every physical thing the shop brings for them is a toggle on that card:

  • Dive bag — whatever the booking rents, each piece reading the size to pull ("BCD · Size M"). A sized piece with nothing on file says No size on file in amber, and the card's Add missing size button opens the size editor in place.
  • Handle with care — dive computers, lights and cameras, in amber. Lights and cameras are add-ons, classified by catalog title (careLabelForAddon) since add-ons have no category column.
  • Add-ons — every other add-on the guest bought (SMBs, nitrox tanks, …), by catalog title.

Tap a piece as it goes on the van; it turns green. Once every piece on a guest is ticked their card folds to one green line, so the list shrinks as the van fills and what is left stays at the top of the screen. A bar above the list counts pieces packed and guests finished. Guests with nothing to pack are still named, on one line under the cards, so a packer can see they were counted. Waitlisted guests sit under their own heading per event and stay out of the day's count until they get a seat.

The list was reorganized around guests after an item-first board let two guests' kit be missed at a shop field-testing FunDive: the old "next-day gear" panel told the shop which BCDs and wetsuits to keep out for tomorrow as anonymous size counts, and nothing tied "also pack" to the two people who needed it. Now the answer to "what goes in the van tomorrow" is tomorrow's own checklist, and a guest can't be satisfied without their name on every piece.

By item turns the same list rack-side for whoever is pulling kit: per item, per size in rack order, one toggle per guest. It ticks the same list, so a piece pulled there reads as packed on its guest's card.

The pieces come from guestPieces() in src/lib/pack-list.ts (pure, unit-tested); piecesByItem() builds the rack view from them.

Ticks are shared with the whole crew. Two people loading one van used to keep two separate lists, so a piece one had packed still read as unpacked on the other's phone — the setup for packing it twice. The list is now the packed_pieces table (20260928100000): a row per piece on the van, keyed by day, booking and item, so a tick is an insert and an untick a delete, and two phones ticking the same piece at once land on one row. Every open board hears a colleague's tick through Supabase Realtime within about a second, and reads the list again in full on reconnect and whenever the page comes back into view (a phone asleep in a pocket misses live updates). Staff and admins read and write it; a tick is stamped packed_by the person who made it; divers and anonymous visitors cannot see it.

A tick shows at once and is also queued on the device (src/lib/gear-packed.ts), so ticking keeps working with no signal. The queue is sent when the connection comes back, and until then the board says how many ticks the rest of the crew cannot see yet. An unsent tick outranks whatever arrives from the server for the same piece, because it is newer. The reads, writes and live channel are src/lib/packed-sync.ts; usePackedGear (src/hooks/usePackedGear.ts) ties them to the page.

A piece is keyed ${bookingId}|${item}; the size is not in the key, so correcting a diver's size on their card doesn't lose the tick. The day is, because a multi-day course is one booking packed afresh each morning. The list lives on AdminLogisticsPage rather than inside GuestPackCard because the guest cards, the by-item view and the waitlist all tick the same day's list.

An item the shop stocks in more than one style is its own piece, because they are separate racks: a felt-soled boot does not cover a diver who asked for rubber. A shop that stocks a style without renting it — say felt soles on the rack and rubber ones only ever brought from home — leaves that style out of gearPrices. Divers still record owning a pair, and the style never reaches a pack list because nobody can book it. See forking.md for how the catalog declares styles.

Reading the board with no signal ​

Today's board is kept on staff and admin devices, so it still reads on a boat. It is read-only there, it says on screen when what you are looking at came off the device rather than the network, and a diver's medical notes, ID number, date of birth and emergency contacts are never written to a device at all. See offline.md.

Event memos (admin_notes) ​

admin_notes is the free-form "sticky note" table for operational flags. Memos surface on the admin event-detail page and the per-diver gear card, and are not visible to divers.

Every memo is:

  • Attached to exactly one target — event_id or booking_id, never both and never neither (CHECK admin_notes_target_present). This is the one XOR left in the schema; events themselves are a single table now.
  • Tagged with one of: urgent, payment, gear, logistics, cert, medical, note, general. The tag drives the color in the UI, and the gear card filters to gear-tagged notes on the booking.
  • Free-text content, 1–2000 chars.
  • Resolvable — when resolved, resolved, resolved_by, and resolved_at are set as a trio (CHECK admin_notes_resolved_consistency). Resolved memos stay in the table but are visually separated.

UI: src/components/admin/AdminNotes.tsx, which takes a target={{ kind: 'event' | 'booking', id }}.

Users ​

/admin/users is a searchable directory:

  • Search matches name and contact_id.
  • Each diver renders as an expandable card showing everything in profiles except id / timestamps: personal · emergency contact · certification · sizing · medical notes.
  • Expanded state also fetches the diver's bookings + a payment summary (paid vs pending totals).
  • Badge next to the name shows diver / admin.
  • Deep link: /admin/users?diver=<id> opens (and scrolls to) that diver's card. Every name on the Logistics board's People section — seated and waitlisted alike — links here, as does the name on each guest's pack card and diver gear card, so a name read while packing can be followed to sizes and contact without retyping it into the search. Staff see plain text instead: the directory is admin-only, so the link would only bounce them.

Revenue by staff ​

/admin/accounting carries two tabs: the document exports it always had, and Revenue — what each paid crew member generated over a season. Staff reach the same route and get only the revenue half, scoped to themselves; the page renders no tab bar and no exports for them, and StaffRevenuePanel narrows the fetch to the events they were actually rostered on.

The attribution rule lives in src/lib/staff-revenue.ts (pure, and unit-tested against every branch):

Kindinstructorguidesupport
course (anything isInstructorLed)earns——
dive / adventureearnsearns—

Two people who both qualify on one event split it evenly.

Who the shop actually pays is not recorded anywhere, and deliberately so: attribution is inferred from the duty roster, so anyone rostered in an earning role shares the event. A compensated-style flag was tried and dropped — it starts false for every existing profile, and a report that silently attributes nothing looks exactly like a report with nothing to attribute. The shop applies its own knowledge of who is on the payroll when reading the numbers.

Revenue is the event's base price times its confirmed heads — the course fee or the dive fee, per student. Deliberately not the money received: what a person generated is a property of the work they did, not of whether an invoice has been settled, and a course whose balance lands in November did not stop being June's teaching. Deliberately not the booking's full total either — gear rental, transport and add-ons are the shop's margin on hire and logistics, not something the instructor generated by teaching.

Each booking's base comes from the charge snapshot taken at registration (bookings.details.charges, the base line), so a later catalog price change cannot retroactively rewrite what a season earned, and a group discount is respected per head. Bookings predating that snapshot fall back to the event's current catalog price (prices.starting_at). See bookingBase in staff-revenue.ts.

Cancelled events drop out entirely; their money is a refund story the Audits page tells. Only confirmed bookings count as heads.

Only events whose last day has passed count toward the season figure. Anything still to come is reported separately as a one-line note, so "what has this person generated" can't be inflated by a trip that hasn't happened.

Events that took money with nobody rostered who could earn from them land in an Unattributed block, admin-only, listing the specific events so duties can be filled in retroactively. Without it the per-person columns would quietly fail to reconcile with what the shop actually took.

An admin gets a crew picker beside the season picker: "All crew" shows the comparison table (click a name to expand it in place), or pick one person for their season alone. The picker lists every admin/staff profile, not only those with revenue — you have to be able to select someone before you can learn they earned nothing.

A person's season reads two ways, both expandable rather than flat:

  • By month — one row per month with its course count, dive count and revenue. Clicking the month lists the events behind those counts, each linking to its event page.
  • By type — two rows, Courses and Dives, each opening into its own breakdown (OW / AOW / EANx under Courses; the dive names under Dives). A group with no work that season is omitted rather than shown empty.

Reachable from Business performance (/admin/dashboard), which links to it beside the historical-perspective link, and from the Manage hub. Staff get it as a nav item, since the dashboard is admin-only.

Database backup ​

/admin/backup hands an admin a copy of the shop's data to keep off the Supabase project: every public table as a CSV, zipped, with a manifest.csv of row counts and a README.txt saying what is and is not in there.

The work happens in the export-database-backup edge function, which runs as service_role (so RLS never trims a backup) and refuses anyone whose profile role is not admin — staff included, since the archive carries every diver's personal details, payments and waiver signatures. It is rate-limited to five a day per admin through the shared take_action_slot limiter.

Two things make it a backup rather than a dump of whatever the schema happened to look like when someone last edited a list:

  • backup_table_inventory() (migration 20260905000000) names the tables, so a table added tomorrow is in tomorrow's backup. It returns each table's columns — an empty table still exports its header — and its primary key, which the function pages by (PostgREST caps a read at 1000 rows, and paging without an ORDER BY can repeat or skip rows).
  • It refuses rather than truncates. Past MAX_TOTAL_ROWS the function returns 413 instead of a silently partial archive.

What it is not: a restore point. There is no schema, no function, no policy, no storage object (certification cards, signed waiver PDFs, dive-site maps) and no auth.users — credentials live outside the shop's own tables. A pg_dump is still the thing to take before a risky migration; this is the copy a shop owner can keep on a laptop and open in a spreadsheet.

Closing down ​

/admin/shutdown (Manage → Close down the shop) is the guided path to switching the app and its infrastructure off. It deletes nothing itself — the destructive steps live in the Supabase and Cloudflare dashboards, where they are already guarded — but it is the thing that stands between an admin and a two-click project deletion:

  1. Readiness checks, computed live from the database: how recently a backup was taken (read from admin_audit_log, which export-database-backup writes to on success), events still on the calendar, money owed to the shop, unspent diver credit (money the shop holds for someone — deleting the project erases the record, not the obligation), and how many diver accounts would go. Each warning links to the page that resolves it. The arithmetic is src/lib/shutdown-readiness.ts, pure and unit-tested.
  2. The exports that outlive the project — links to the CSV backup and the bookkeeping / waiver ZIPs, plus a note about retention periods.
  3. A farewell push to every subscribed device, prefilled, through the same /admin-broadcast endpoint the notifications page uses (src/lib/push-broadcast.ts, extracted so the two cannot drift).
  4. The ordered switch-off checklist — domain, app worker, push worker, Turnstile, Supabase project, Gmail app password, repository, accounts. Ticks persist in localStorage, because closing a shop takes more than one sitting.

Two details worth keeping: the figures are read with fetchAllRows (src/lib/fetch-all.ts) rather than a plain select, because PostgREST caps a response at 1000 rows silently and "money still owed" is not a number to under-report; and the long-form walkthrough — account closure, DNS, storage buckets, what to keep for tax purposes — lives in shutdown.md, which the page links out to.

Role-view toggle ​

Admins can switch between diver and admin shells without logging out:

  • AppShell (diver) shows a "View as admin" link when profile.role === 'admin'.
  • AdminShell shows a "View as diver" link regardless.

Both are just navigation — they don't change the user's role or privileges. It exists so an admin can look at the diver experience with their own test bookings.

Permissions cheat-sheet ​

WhatDiverStaffAdmin
Read own profile / bookings / paymentsyesyesyes
Read any profile / bookings / paymentsnoyesyes
Create / update own bookingsyesyesyes
Insert / update paymentsnonoyes
Ask for a discount (own booking, or a child's)yesyesyes
Approve / reject a discountnonoyes
Create / resolve event memosnonoyes
Read & insert admin_notes (own attribution)noyesyes
Update / delete admin_notesnonoyes
Manage EO_* catalog (new/edit/rooms/addons/travel/prices)nonoyes
Assign dutiesnonoyes
Be assigned a duty (trigger gate)noyesyes
Read own revenue by seasonnoyesyes
Read anyone's revenue / the unattributed bucketnonoyes
Send broadcast pushnonoyes

The actual enforcement lives in RLS policies in the migrations (is_admin() for writes; is_staff_or_admin() for the shared reads).

Released under the AGPL-3.0-or-later license.