CRM Build Spec · crm-v1.0

Setpoint CRM — v1.0 Foundation

Complete spec for the CRM Foundation — Next.js app scaffold, auth via Setpoint DB, facility selector, CRM sidebar, customer directory, contact detail, staff notes, colour tags, and deploy to Vercel. Interface matches PMS admin exactly.

Planned9 screens3 new tables10 API routes8 build phases/24 AC done

Stack

Tech Stack & Architecture

app

Next.js 16 (App Router, Turbopack) — mirrors apps/admin

styling

Tailwind CSS 4 + shadcn/ui — same globals.css tokens as PMS

auth

Supabase Auth — same auth.users table, signInWithPassword

database

Supabase Postgres — same DB (utommwznukufegwhltqw)

packages

@setpoint/database, @setpoint/common

deployment

Vercel — project crm-setpoint, root directory apps/crm

node

24.x (matches all other Setpoint apps)

Structure

Monorepo File Structure

apps/crm/
apps/crm/CRM Next.js app root
apps/crm/package.jsonname: "@setpoint/crm", same deps as admin
apps/crm/next.config.tsLoad .env.local from repo root, transpile shared packages
apps/crm/tsconfig.jsonExtends root tsconfig, path alias @/ → src/
apps/crm/postcss.config.mjs@tailwindcss/postcss plugin — same as admin
apps/crm/src/middleware.tsAuth guard — same pattern as admin middleware
apps/crm/src/app/globals.cssIdentical to apps/admin globals.css — same design tokens
apps/crm/src/app/layout.tsxRoot layout — Geist fonts, ThemeProvider, Toaster
apps/crm/src/app/page.tsxRoot page — fetch user facilities, redirect or show selector
apps/crm/src/app/auth/login/page.tsxLogin — email + password, title: Setpoint CRM
apps/crm/src/app/[facility]/layout.tsxFacility layout — CrmSidebar + main content
apps/crm/src/app/[facility]/page.tsxDashboard — 4 KPI cards + recent contacts
apps/crm/src/app/[facility]/contacts/page.tsxContacts list — search, filter, sortable table
apps/crm/src/app/[facility]/contacts/[id]/page.tsxContact detail — profile, stats, notes, tags
apps/crm/src/app/[facility]/segments/page.tsxSegments — placeholder (v1.2)
apps/crm/src/app/[facility]/campaigns/page.tsxCampaigns — placeholder (v1.3)
apps/crm/src/app/[facility]/loyalty/page.tsxLoyalty — placeholder (v1.4)
apps/crm/src/app/[facility]/settings/page.tsxSettings — placeholder
apps/crm/src/components/crm/crm-sidebar.tsxSidebar — identical structure to admin-sidebar.tsx
apps/crm/src/components/crm/crm-facility-switcher.tsxFacility switcher — identical to PMS facility-switcher.tsx
apps/crm/src/components/crm/contacts-table.tsxContacts sortable table with avatar, tags, stats
apps/crm/src/components/crm/contact-stat-card.tsxStat card used on contact detail and dashboard
apps/crm/src/components/crm/notes-panel.tsxNotes list + add/delete form
apps/crm/src/components/crm/tag-chip.tsxColour tag chip + add/remove UI
apps/crm/src/components/ui/Full shadcn/ui component set — copied 1:1 from apps/admin/src/components/ui/

Interface

Screen-by-Screen Specification

Login/auth/loginmirrors PMS

Email + password login for CRM staff. Identical layout to PMS admin login.

Layout

Full-screen centered card (max-w-sm)

UI Elements

  • Zap icon in rounded-xl bg-primary header (same as PMS)
  • Title: "Setpoint CRM" (PMS uses "Setpoint Admin")
  • Subtitle: "Sign in to manage your customer relationships"
  • Email input
  • Password input
  • Sign In button (full-width, primary)
  • Error message on failed auth (text-destructive)
  • Link to signup (same /auth/signup path)

PMS Equivalent

apps/admin/src/app/auth/login/page.tsx

Data Sources

  • supabase.auth.signInWithPassword({ email, password })
  • On success → window.location.href = redirectTo (preserves ?redirect= param)
Facility Selector/mirrors PMS

Logged-in users see all their facilities. Auto-redirects to /[facility]/contacts if only one. Identical layout to PMS admin home.

Layout

Full-screen centered container (max-w-2xl)

UI Elements

  • Zap icon header
  • Title: "Setpoint CRM"
  • Subtitle: "Select a facility"
  • + Add Facility button (deferred — shows "Coming soon" toast in v1.0)
  • One card per facility (clickable → /[facility]/contacts):
  • → Facility logo or initial avatar
  • → Facility name + city/state
  • → Role badge (owner / admin / manager)
  • → ChevronRight icon
  • → 3 stat chips: Total Contacts | New This Month | Active This Month
  • Empty state if no facilities: 'You don't have access to any facilities.'

PMS Equivalent

apps/admin/src/app/page.tsx

Data Sources

  • user_facility_roles → facility IDs for current user
  • facilities → name, slug, city, state, logo_url
  • customers → COUNT(DISTINCT id) WHERE facility_id matches (via bookings or pos_orders)
  • Auto-redirect: if facilities.length === 1 → /[slug]/contacts

Note

Stats on facility card are CRM-relevant (contacts, new this month) not PMS ones (today's bookings, revenue).

CRM Sidebar/[facility]/*mirrors PMS

Collapsible icon sidebar — identical structure to PMS admin sidebar. w-16 collapsed, w-48 on hover.

Layout

sticky h-screen flex-col border-r bg-sidebar

UI Elements

  • Top: CrmFacilitySwitcher (same as PMS FacilitySwitcher)
  • Nav items (icon + label, tooltip on collapsed):
  • → BarChart3 Dashboard /
  • → Users Contacts /contacts
  • → Layers Segments /segments
  • → Megaphone Campaigns /campaigns
  • → Star Loyalty /loyalty
  • → Settings Settings /settings
  • Bottom: ThemeToggle (same as PMS)
  • Bottom: User dropdown — Profile / Settings / Sign out (same as PMS)
  • Active item: bg-sidebar-accent text-sidebar-accent-foreground
  • Inactive item: text-sidebar-foreground/70 hover:bg-sidebar-accent/50

PMS Equivalent

apps/admin/src/components/admin/admin-sidebar.tsx

Data Sources

  • usePathname() to derive facilitySlug and active state
  • createBrowserClient for sign-out
  • GET /api/crm/facilities/my for facility switcher list

Note

CRM sidebar uses BarChart3 for Dashboard instead of Calendar. Otherwise identical interaction pattern.

Dashboard/[facility]mirrors PMS

CRM overview with 4 KPI cards and a recent contacts preview table.

Layout

p-6, flex flex-col gap-6

UI Elements

  • Page header: "Dashboard" + "Customer overview for [facility name]"
  • 4 KPI stat cards (same card component as PMS reports):
  • → Total Contacts — all customers with activity at this facility
  • → New This Month — customers created in current calendar month
  • → Active This Month — customers with booking or POS order this month
  • → At Risk — customers with zero activity in last 30 days (of previously active)
  • Recent Contacts section:
  • → Section header 'Recent Contacts' + 'View all' link → /contacts
  • → Table: avatar, name, email, last visit, total bookings, tags
  • → Last 10 contacts ordered by most recent booking/order date
  • Quick Links row:
  • → 'Add a note' → /contacts (highlight search)
  • → 'Browse all contacts' → /contacts

PMS Equivalent

apps/admin/src/app/[facility]/reports/page.tsx (card pattern)

Data Sources

  • customers JOIN bookings ON user_id (facility_id scoped) — total count
  • customers WHERE created_at >= date_trunc('month', now())
  • customers with booking or pos_order in current month
  • customers with last booking < now() - interval '30 days' AND has_prior_activity
  • Recent contacts: customers ORDER BY last_activity_at DESC LIMIT 10
Contacts/[facility]/contactsmirrors PMS

Full customer directory — search, filter by status, sortable table with aggregated stats.

Layout

p-6, flex flex-col gap-6

UI Elements

  • Page header: "Contacts" + "All customers at [facility name]"
  • Stats bar (3 chips): Total | Active | Members
  • Toolbar row:
  • → Search input (placeholder: 'Search by name, email or phone...')
  • → Filter tabs: All | Active | Inactive | Members
  • → Sort dropdown: Name / Last Visit / Total Spend / Bookings
  • Contacts table (sortable columns):
  • → Avatar (PlayerAvatar component — image or initials, md size)
  • → Name (clickable → contact detail, font-medium)
  • → Email (text-muted-foreground, text-sm)
  • → Phone
  • → Tags (up to 3 tag chips, +N overflow badge)
  • → Total Bookings (number)
  • → Last Visit (relative date — '3 days ago')
  • → Membership (badge: None / plan name)
  • → Total Spend (formatted currency)
  • Pagination: 50 per page, prev/next buttons
  • Empty state: 'No contacts found' with illustration if search returns nothing

PMS Equivalent

apps/admin/src/app/[facility]/customers/page.tsx

Data Sources

  • customers WHERE id IN (SELECT user_id FROM bookings WHERE facility_id = $1 UNION SELECT user_id FROM pos_orders WHERE facility_id = $1)
  • JOIN crm_customer_tags → crm_tags for tag chips
  • LEFT JOIN memberships ON customers.id for membership column
  • Aggregates via subquery: COUNT(bookings), MAX(bookings.booking_date) as last_visit, SUM(pos_orders.total_cents)
  • Search: ILIKE on first_name, last_name, email, phone
  • Sort: applied to ORDER BY clause server-side

Note

The contacts page replaces 'Customers' in PMS. Same table pattern but adds Tags column and richer stats.

Contact Detail/[facility]/contacts/[id]

Full customer profile — identity, aggregated stats, recent history, staff notes, and tags.

Layout

p-6, grid gap-6 lg:grid-cols-[1fr_340px]

UI Elements

  • Top bar: ← Back to Contacts (ArrowLeft) + breadcrumb
  • Profile header card (full width):
  • → PlayerAvatar (lg size — 64px)
  • → Full name (text-2xl font-bold)
  • → Email + phone (text-muted-foreground)
  • → Join date (CalendarDays icon + 'Member since [date]')
  • → Tags row: colour chips + [+ Add Tag] button (opens tag picker popover)
  • Left column — main content:
  • → 3 stat cards (Total Bookings | Last Visit | Total POS Spend)
  • → Booking stats mini-section: Most played sport | No-show rate | This month count
  • → Recent Bookings table (last 5): date, sport, court, duration, status, price
  • → Recent POS Orders table (last 5): date, items summary, total
  • → Membership card: plan name, status badge (active/inactive), since date
  • Right column — notes panel:
  • → Section header 'Staff Notes' + note count
  • → Add note form: Textarea (placeholder 'Add a private note...') + Save button
  • → Notes list (newest first):
  • → Note content
  • → Created by name + relative timestamp
  • → Delete button (X, visible on hover, only own notes or admin)
  • → Empty state: 'No notes yet. Add the first one above.'

Data Sources

  • customers WHERE id = $contactId
  • bookings WHERE user_id = $contactId AND facility_id = $facilityId ORDER BY start_time DESC LIMIT 5
  • pos_orders WHERE user_id = $contactId AND facility_id = $facilityId ORDER BY created_at DESC LIMIT 5
  • memberships WHERE profile_id = $contactId AND facility_id = $facilityId
  • crm_notes WHERE profile_id = $contactId AND facility_id = $facilityId ORDER BY created_at DESC
  • crm_customer_tags JOIN crm_tags WHERE profile_id = $contactId AND crm_customer_tags.facility_id = $facilityId
  • POST /api/crm/notes → INSERT crm_notes
  • DELETE /api/crm/notes/[id] → DELETE crm_notes WHERE id AND (created_by = user OR role IN owner/admin)
  • POST /api/crm/tags → INSERT crm_customer_tags
  • DELETE /api/crm/tags/[tagId]/contacts/[contactId] → DELETE crm_customer_tags

Note

Two-column layout on desktop (lg). Single column on mobile. Notes panel is always visible (no tab required for v1.0).

Segments (Placeholder)/[facility]/segments

Placeholder page — ships as empty state in v1.0, implemented in v1.2.

Layout

p-6, flex flex-col gap-6

UI Elements

  • Page header: "Segments" + "Smart customer groups"
  • Empty state card (centred, max-w-md):
  • → Layers icon (h-12 w-12, text-muted-foreground)
  • → "Segments coming in v1.2"
  • → "Group customers by behaviour, sport, inactivity, and more — automatically."
Campaigns (Placeholder)/[facility]/campaigns

Placeholder page — ships in v1.3.

Layout

p-6

UI Elements

  • Page header: "Campaigns"
  • Empty state: Megaphone icon + "Campaigns coming in v1.3" + description
Loyalty (Placeholder)/[facility]/loyalty

Placeholder page — ships in v1.4.

Layout

p-6

UI Elements

  • Page header: "Loyalty"
  • Empty state: Star icon + "Loyalty & Points coming in v1.4" + description

Database

Database Schema — New Tables

All three tables use the crm_ prefix and are facility-scoped with RLS using the existing user_facility_role() helper function. Existing tables (customers, facilities, bookings, pos_orders, memberships) are read-only from the CRM.

crm_notes

Private staff notes attached to a customer profile at a specific facility. Visible only to facility staff.

DDL

CREATE TABLE crm_notes (
  id           UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  facility_id  UUID        NOT NULL REFERENCES facilities(id) ON DELETE CASCADE,
  profile_id   UUID        NOT NULL REFERENCES customers(id)  ON DELETE CASCADE,
  content      TEXT        NOT NULL CHECK (char_length(content) <= 2000),
  created_by   UUID        NOT NULL REFERENCES customers(id),
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

RLS Policies

  • SELECT: user_facility_role(facility_id) IN ('owner','admin','manager','coach')
  • INSERT: user_facility_role(facility_id) IN ('owner','admin','manager','coach'), created_by = auth.uid()
  • UPDATE: created_by = auth.uid() OR user_facility_role(facility_id) IN ('owner','admin')
  • DELETE: created_by = auth.uid() OR user_facility_role(facility_id) IN ('owner','admin')

Indexes

  • idx_crm_notes_profile (profile_id, facility_id) — contact detail page query
  • idx_crm_notes_facility (facility_id, created_at DESC) — facility-level note queries
crm_tags

Tag definitions per facility. Name must be unique within a facility. Colour is a hex string for the chip background.

DDL

CREATE TABLE crm_tags (
  id           UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  facility_id  UUID        NOT NULL REFERENCES facilities(id) ON DELETE CASCADE,
  name         TEXT        NOT NULL CHECK (char_length(name) BETWEEN 1 AND 30),
  colour       TEXT        NOT NULL DEFAULT '#6B7280',
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (facility_id, name)
);

RLS Policies

  • SELECT: user_facility_role(facility_id) IS NOT NULL (any staff role)
  • INSERT: user_facility_role(facility_id) IN ('owner','admin','manager')
  • UPDATE: user_facility_role(facility_id) IN ('owner','admin','manager')
  • DELETE: user_facility_role(facility_id) IN ('owner','admin','manager')

Indexes

  • idx_crm_tags_facility (facility_id) — tag picker dropdown query
crm_customer_tags

Join table assigning tags to customers within a facility. One row per tag+customer+facility combination.

DDL

CREATE TABLE crm_customer_tags (
  tag_id       UUID        NOT NULL REFERENCES crm_tags(id)    ON DELETE CASCADE,
  profile_id   UUID        NOT NULL REFERENCES customers(id)    ON DELETE CASCADE,
  facility_id  UUID        NOT NULL REFERENCES facilities(id)  ON DELETE CASCADE,
  assigned_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  assigned_by  UUID        REFERENCES customers(id),
  PRIMARY KEY (tag_id, profile_id)
);

RLS Policies

  • SELECT: user_facility_role(facility_id) IS NOT NULL
  • INSERT: user_facility_role(facility_id) IN ('owner','admin','manager','coach')
  • DELETE: user_facility_role(facility_id) IN ('owner','admin','manager','coach')

Indexes

  • idx_crm_customer_tags_profile (profile_id, facility_id) — contact detail + contacts list tags
  • idx_crm_customer_tags_tag (tag_id) — tag-based filtering

API

API Routes (10 endpoints)

GET/api/crm/facilities/my

Return all facilities the current user has a role at (for sidebar switcher)

Auth: JWT (session cookie via createClient())Returns: { facilities: [{ id, slug, name, city, state, logo_url, role, contact_count }] }
GET/api/crm/[facility]/dashboard

Return 4 KPI metrics + 10 most recent contacts for the dashboard

Auth: JWT + facility role checkReturns: { totalContacts, newThisMonth, activeThisMonth, atRisk, recentContacts[] }
GET/api/crm/[facility]/contacts

Paginated, searchable, sortable contacts list with aggregated stats

Auth: JWT + facility role checkParams: ?q=&filter=all|active|inactive|members&sort=name|last_visit|spend|bookings&page=1Returns: { contacts: ContactRow[], total: number, page: number }
GET/api/crm/[facility]/contacts/[id]

Full contact profile, stats, recent bookings, recent POS orders, membership, notes, tags

Auth: JWT + facility role checkReturns: { profile, stats, recentBookings[], recentOrders[], membership, notes[], tags[] }
POST/api/crm/[facility]/contacts/[id]/notes

Create a new staff note for the contact

Auth: JWT + facility role (owner/admin/manager/coach)Params: { content: string }Returns: { note: CrmNote }
DELETE/api/crm/[facility]/contacts/[id]/notes/[noteId]

Delete a note. Only the author or owner/admin can delete.

Auth: JWT + (created_by = user OR facility role owner/admin)Returns: { success: true }
GET/api/crm/[facility]/tags

Return all tag definitions for a facility (for tag picker popover)

Auth: JWT + any facility roleReturns: { tags: CrmTag[] }
POST/api/crm/[facility]/tags

Create a new tag definition for the facility

Auth: JWT + owner/admin/managerParams: { name: string, colour: string }Returns: { tag: CrmTag }
POST/api/crm/[facility]/contacts/[id]/tags

Assign an existing tag to a contact

Auth: JWT + owner/admin/manager/coachParams: { tagId: string }Returns: { success: true }
DELETE/api/crm/[facility]/contacts/[id]/tags/[tagId]

Remove a tag from a contact

Auth: JWT + owner/admin/manager/coachReturns: { success: true }

Build Plan

Implementation Phases

1
Scaffold & Design SystemStep 1

feat(crm): scaffold Next.js 16 app with design system

  • Create apps/crm/ directory in monorepo
  • Write package.json — name @setpoint/crm, copy deps from admin (next 16, tailwind 4, shadcn, supabase/ssr)
  • Write next.config.ts — dotenv root .env.local, transpilePackages
  • Write tsconfig.json — extends root, @/ alias
  • Write postcss.config.mjs — @tailwindcss/postcss
  • Copy apps/admin/src/app/globals.css → apps/crm/src/app/globals.css (identical — same design tokens)
  • Copy all apps/admin/src/components/ui/* → apps/crm/src/components/ui/ (full shadcn set)
  • Write apps/crm/src/app/layout.tsx — Geist fonts, ThemeProvider, Toaster
  • Run npm install from repo root
2
Auth & MiddlewareStep 2

feat(crm): auth middleware and login page

  • Write apps/crm/src/middleware.ts — copy from admin middleware, same logic
  • Write apps/crm/src/app/auth/login/page.tsx — copy admin login, change title to 'Setpoint CRM'
  • Write apps/crm/src/app/auth/signup/page.tsx — link back to login only (no self-serve signup for CRM in v1.0)
  • Verify: unauthenticated visit to /[facility]/contacts redirects to /auth/login
  • Verify: successful login redirects back to original URL
3
Facility Selector & SidebarStep 3

feat(crm): facility selector and CRM sidebar navigation

  • Write GET /api/crm/facilities/my — fetch user roles, join facilities, return with contact_count stat
  • Write apps/crm/src/app/page.tsx — facility selector (mirrors admin page.tsx, uses contact_count not booking count)
  • Write apps/crm/src/components/crm/crm-facility-switcher.tsx — copy FacilitySwitcher, change endpoint to /api/crm/facilities/my
  • Write apps/crm/src/components/crm/crm-sidebar.tsx — copy admin-sidebar.tsx, replace NAV_ITEMS with CRM items
  • Write apps/crm/src/app/[facility]/layout.tsx — CrmSidebar + main
  • Verify: sidebar collapses to w-16, expands to w-48 on hover
  • Verify: active nav item highlighted, facility switcher shows correct name
4
Database MigrationStep 4

feat(crm): database migration — crm_notes, crm_tags, crm_customer_tags

  • Write supabase/migrations/[timestamp]_crm_v1_foundation.sql
  • Create crm_notes table with RLS (4 policies using user_facility_role())
  • Create crm_tags table with RLS (4 policies)
  • Create crm_customer_tags table with RLS (3 policies)
  • Add all indexes (6 total)
  • Write updated_at trigger for crm_notes (reuse existing trigger pattern)
  • Add seed data to supabase/seed.sql:
  • 3 sample tags per facility (VIP #gold, Regular #blue, Inactive #gray)
  • 2 sample notes per test contact
  • Tag assignments for test contacts
  • Run supabase db reset locally to verify migration applies cleanly
5
Contacts ListStep 5

feat(crm): contacts list with search, filter, and sortable table

  • Write GET /api/crm/[facility]/contacts — paginated query with search/filter/sort
  • Build contact aggregation query: JOIN bookings, pos_orders, crm_customer_tags, memberships
  • Write apps/crm/src/components/crm/contacts-table.tsx — sortable table, PlayerAvatar, tag chips
  • Write apps/crm/src/app/[facility]/contacts/page.tsx — search bar, filter tabs, table, pagination
  • Verify: search by name/email/phone filters results
  • Verify: Active filter shows only contacts with booking in last 90 days
  • Verify: Members filter shows only contacts with active membership
  • Verify: clicking a row navigates to /[facility]/contacts/[id]
6
Contact Detail, Notes & TagsStep 6

feat(crm): contact detail page with notes and tags

  • Write GET /api/crm/[facility]/contacts/[id] — profile + all aggregates
  • Write POST/DELETE /api/crm/[facility]/contacts/[id]/notes
  • Write GET/POST /api/crm/[facility]/tags
  • Write POST/DELETE /api/crm/[facility]/contacts/[id]/tags/[tagId]
  • Write apps/crm/src/components/crm/notes-panel.tsx — add note form + notes list
  • Write apps/crm/src/components/crm/tag-chip.tsx — chip + tag picker popover
  • Write apps/crm/src/app/[facility]/contacts/[id]/page.tsx — two-column layout
  • Verify: adding a note saves to crm_notes and appears instantly
  • Verify: deleting own note works; deleting other's note blocked unless admin
  • Verify: adding tag from picker assigns to contact and chip appears
  • Verify: removing tag chip removes from crm_customer_tags
7
Dashboard & Placeholder PagesStep 7

feat(crm): dashboard KPIs and placeholder pages

  • Write GET /api/crm/[facility]/dashboard — 4 KPI metrics + 10 recent contacts
  • Write apps/crm/src/app/[facility]/page.tsx — KPI stat cards + recent contacts table
  • Write placeholder pages: segments, campaigns, loyalty, settings (empty states with version label)
  • Verify: dashboard KPIs match manual DB counts
  • Verify: placeholder pages render correctly with empty state copy
8
Deploy to VercelStep 8

chore: deploy CRM v1.0 to Vercel (crm-setpoint)

  • Create Vercel project crm-setpoint linked to apps/crm root directory
  • Configure env vars on Vercel: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY
  • Set Node.js version to 24.x
  • Run vercel deploy --prod to push first production build
  • Verify: https://crm-setpoint.vercel.app/auth/login renders
  • Verify: login with test admin credentials works
  • Verify: contacts list loads with real data from production DB
  • Update CLAUDE.md deployment table with crm-setpoint project details

Seed Data

Seed Data Requirements

  • 3 crm_tags per facility (VIP, Regular, At Risk) with distinct hex colours
  • crm_customer_tags: assign VIP tag to 2 contacts, Regular to 5 contacts, At Risk to 2 contacts
  • crm_notes: 2 notes per VIP contact, 1 note per At Risk contact (varied content)
  • Notes created_by must reference a valid staff profile (use owner profile UUID from seed)
  • Seed covers: empty states (contacts with no notes, contacts with no tags), populated states, multi-tag contacts

QA

Acceptance Criteria (/24 complete)

Login page renders at /auth/login — email + password, signs in with PMS admin credentials
Unauthenticated visit to any route redirects to /auth/login with ?redirect= param
Successful login redirects to original URL
Facility selector shows all user facilities with contact count, auto-redirects if only one
Sidebar collapses to icons (w-16), expands on hover (w-48), tooltips on collapsed state
Sidebar active item highlights correctly on all routes
Facility switcher switches facilities and preserves current sub-path
Dashboard shows 4 correct KPI values matching DB counts
Dashboard recent contacts table shows last 10 contacts
Contacts list loads all contacts for the facility (test with Sunset Padel seed data)
Search filters contacts by name, email, phone (case-insensitive)
Active filter shows only contacts with activity in last 90 days
Members filter shows only contacts with active membership
Sort by name, last visit, total spend, bookings all work correctly
Clicking a contact row navigates to /[facility]/contacts/[id]
Contact detail shows correct profile info, stats, recent bookings, recent orders
Contact detail membership card shows correct plan and status
Adding a note saves and renders immediately without page reload
Deleting own note removes it. Deleting another's note blocked (403) unless owner/admin
Adding a tag from picker assigns it and chip renders on profile header
Removing a tag chip removes the assignment from DB
Placeholder pages (segments, campaigns, loyalty) render empty states with correct copy
Dark mode works on all pages (ThemeToggle in sidebar)
Deployed at https://crm-setpoint.vercel.app — login and contacts list work in production