Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion app/admin/matches/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { LEVEL_LABELS } from '@/lib/utils/request'
import { ReassignTutorForm, EditMatchForm, GenerateSessionsForm, DeleteSessionsForm, AdminNotesForm } from './MatchActions'
import { CopyMessageButton } from '@/components/CopyMessageButton'
import { WhatsAppLink } from '@/components/WhatsAppLink'
import { AdminBreadcrumbs } from '@/components/admin/AdminBreadcrumbs'
import { templates } from '@/lib/whatsapp/templates'

const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
Expand Down Expand Up @@ -163,6 +164,14 @@ export default async function AdminMatchDetailPage({

return (
<div className="mx-auto max-w-3xl space-y-6">
<AdminBreadcrumbs
items={[
{ label: 'Admin', href: '/admin' },
{ label: 'Matches', href: '/admin/matches' },
{ label: 'Match Detail' },
]}
/>

{assigned === '1' && (
<div className="border-2 border-[#121212] bg-white px-6 py-4 text-[#121212]">
<p className="font-semibold">Tutor assigned successfully.</p>
Expand Down Expand Up @@ -210,7 +219,23 @@ export default async function AdminMatchDetailPage({
<dl className="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-[#121212]/60">Student</dt>
<dd className="font-medium text-[#121212]">{studentName}</dd>
<dd className="font-medium text-[#121212]">
{studentProfile?.display_name ? (
<Link
href={`/admin/users?search=${encodeURIComponent(studentProfile.display_name)}`}
className="text-[#1040C0] underline-offset-4 hover:underline"
>
{studentProfile.display_name}
</Link>
) : (
studentName
)}
{request?.requester_role === 'parent' && request?.for_student_name && (
<span className="ml-1 text-xs text-[#121212]/50">
(Student: {request.for_student_name})
</span>
)}
</dd>
{studentProfile?.whatsapp_number && (
<dd className="mt-1 flex items-center gap-2 text-xs text-[#121212]/40">
📱 {studentProfile.whatsapp_number}
Expand All @@ -225,6 +250,14 @@ export default async function AdminMatchDetailPage({
{tutorUserProfile?.display_name ?? '—'}
</dd>
<dd className="text-xs text-[#121212]/40">{tutorProfile?.timezone}</dd>
<dd className="mt-1 text-xs">
<Link
href={`/admin/tutors/${match.tutor_user_id}`}
className="font-bold text-[#1040C0] underline-offset-4 hover:underline"
>
View tutor profile
</Link>
</dd>
{tutorUserProfile?.whatsapp_number && (
<dd className="mt-1 flex items-center gap-2 text-xs text-[#121212]/40">
📱 {tutorUserProfile.whatsapp_number}
Expand Down
3 changes: 2 additions & 1 deletion app/admin/payments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PaymentQuickActions } from './PaymentQuickActions'
import Link from 'next/link'
import { templates } from '@/lib/whatsapp/templates'
import { PAYMENT_INSTRUCTIONS } from '@/lib/config/pricing'
import { formatPkr } from '@/lib/utils/currency'
import { AdminPagination, PAGE_SIZE } from '@/components/AdminPagination'

const STATUS_COLOURS: Record<string, string> = {
Expand Down Expand Up @@ -149,7 +150,7 @@ export default async function AdminPaymentsPage({
{subjectName} · {level} · {pkg?.tier_sessions ?? '?'} sessions/month
</p>
<p className="text-sm text-[#121212]/60">
Amount: <span className="font-bold text-[#121212]">PKR {payment.amount_pkr.toLocaleString()}</span>
Amount: <span className="font-bold text-[#121212]">{formatPkr(payment.amount_pkr)}</span>
</p>
<p className="text-xs text-[#121212]/40">Submitted: {submittedDate}</p>
{payment.reference && (
Expand Down
31 changes: 31 additions & 0 deletions app/admin/requests/RequestFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,22 @@ type RequestFiltersProps = {
activeStatus: string
activeSubject: string | undefined
activeLevel: string | undefined
activeSearch: string | undefined
}

export function RequestFilters({
subjects,
activeStatus,
activeSubject,
activeLevel,
activeSearch,
}: RequestFiltersProps) {
function buildHref(params: Record<string, string | undefined>): string {
const merged: Record<string, string | undefined> = {
status: activeStatus !== 'all' ? activeStatus : undefined,
subject: activeSubject,
level: activeLevel,
q: activeSearch,
...params,
}
const qs = Object.entries(merged)
Expand All @@ -34,6 +37,34 @@ export function RequestFilters({

return (
<div className="flex flex-wrap gap-3">
<form action="/admin/requests" className="flex flex-wrap gap-2">
{activeStatus !== 'all' && <input type="hidden" name="status" value={activeStatus} />}
{activeSubject && <input type="hidden" name="subject" value={activeSubject} />}
{activeLevel && <input type="hidden" name="level" value={activeLevel} />}
<input
type="search"
name="q"
defaultValue={activeSearch ?? ''}
placeholder="Search student, requester, or subject"
className="min-h-[38px] min-w-[260px] border-2 border-[#121212] px-3 py-1.5 text-sm"
aria-label="Search requests"
/>
<button
type="submit"
className="border-2 border-[#121212] bg-[#121212] px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-white"
>
Search
</button>
{activeSearch && (
<a
href={buildHref({ q: undefined })}
className="inline-flex min-h-[38px] items-center border-2 border-[#B0B0B0] bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-[#121212]/70 hover:border-[#1040C0] hover:text-[#1040C0]"
>
Clear
</a>
)}
</form>

{/* Subject filter */}
<select
value={activeSubject ?? ''}
Expand Down
41 changes: 36 additions & 5 deletions app/admin/requests/[id]/AssignTutorForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { assignTutor } from '../actions'
import {
type AvailabilityWindow,
getAvailabilityOverlapSummary,
} from '@/lib/utils/availability'

const DAY_OPTIONS = [
{ label: 'Sun', value: 0 },
Expand All @@ -19,14 +23,12 @@ const DAY_OPTIONS = [

const DAY_SHORT = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']

type AvailWindow = { day: number; start: string; end: string }

export type EligibleTutor = {
tutor_user_id: string
bio: string | null
timezone: string
user_profiles: { display_name: string; whatsapp_number: string | null } | null
tutor_availability: { windows: AvailWindow[] } | null
tutor_availability: { windows: AvailabilityWindow[] } | null
}

type ActionResult = { error?: string; matchId?: string } | undefined
Expand Down Expand Up @@ -59,13 +61,13 @@ async function assignAction(
}

/** Visual availability calendar: 7 columns (days), time-block rows colour-coded */
function AvailabilityCalendar({ windows }: { windows: AvailWindow[] }) {
function AvailabilityCalendar({ windows }: { windows: AvailabilityWindow[] }) {
if (windows.length === 0) {
return <p className="text-xs text-[#121212]/40 italic">No availability set</p>
}

// Group windows by day
const byDay: Record<number, AvailWindow[]> = {}
const byDay: Record<number, AvailabilityWindow[]> = {}
for (const w of windows) {
if (!byDay[w.day]) byDay[w.day] = []
byDay[w.day].push(w)
Expand Down Expand Up @@ -100,18 +102,41 @@ function AvailabilityCalendar({ windows }: { windows: AvailWindow[] }) {
)
}

function formatOverlapWindows(overlaps: AvailabilityWindow[]): string {
return overlaps
.map((window) => `${DAY_OPTIONS.find((day) => day.value === window.day)?.label ?? window.day} ${window.start}-${window.end}`)
.join(', ')
}

/** Single collapsible tutor card */
function TutorCard({
tutor,
requestAvailabilityWindows,
isSelected,
onSelect,
}: {
tutor: EligibleTutor
requestAvailabilityWindows: AvailabilityWindow[]
isSelected: boolean
onSelect: () => void
}) {
const [expanded, setExpanded] = useState(isSelected)
const windows = tutor.tutor_availability?.windows ?? []
const overlap = getAvailabilityOverlapSummary(requestAvailabilityWindows, windows)
const overlapLabel =
overlap.status === 'overlap'
? `Overlap: ${formatOverlapWindows(overlap.overlaps)}`
: overlap.status === 'none'
? 'No requested-time overlap'
: overlap.status === 'missing_request'
? 'Request availability missing'
: 'Tutor availability missing'
const overlapClass =
overlap.status === 'overlap'
? 'border-[#1040C0] bg-[#1040C0]/10 text-[#1040C0]'
: overlap.status === 'none'
? 'border-[#D02020] bg-[#D02020]/10 text-[#D02020]'
: 'border-[#F0C020] bg-[#F0C020]/20 text-[#121212]'
const name = tutor.user_profiles?.display_name ?? '—'

return (
Expand All @@ -137,6 +162,9 @@ function TutorCard({
className="h-4 w-4 flex-shrink-0 accent-[#1040C0]"
/>
<span className="flex-1 font-semibold text-[#121212]">{name}</span>
<span className={`border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${overlapClass}`}>
{overlapLabel}
</span>
<span className="text-xs text-[#121212]/40">{tutor.timezone}</span>
<button
type="button"
Expand Down Expand Up @@ -177,10 +205,12 @@ function TutorCard({
export function AssignTutorForm({
requestId,
requestTimezone,
requestAvailabilityWindows,
eligibleTutors,
}: {
requestId: string
requestTimezone: string
requestAvailabilityWindows: AvailabilityWindow[]
eligibleTutors: EligibleTutor[]
}) {
const [selectedTutorId, setSelectedTutorId] = useState<string | null>(null)
Expand Down Expand Up @@ -223,6 +253,7 @@ export function AssignTutorForm({
<TutorCard
key={tutor.tutor_user_id}
tutor={tutor}
requestAvailabilityWindows={requestAvailabilityWindows}
isSelected={selectedTutorId === tutor.tutor_user_id}
onSelect={() => setSelectedTutorId(tutor.tutor_user_id)}
/>
Expand Down
10 changes: 10 additions & 0 deletions app/admin/requests/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { STATUS_COLOURS, STATUS_LABELS, LEVEL_LABELS } from '@/lib/utils/request
import { AssignTutorForm } from './AssignTutorForm'
import { AdminRequestActions } from './AdminRequestActions'
import { WhatsAppLink } from '@/components/WhatsAppLink'
import { AdminBreadcrumbs } from '@/components/admin/AdminBreadcrumbs'

const EXAM_BOARD_LABELS: Record<string, string> = {
cambridge: 'Cambridge',
Expand Down Expand Up @@ -148,6 +149,14 @@ export default async function AdminRequestDetailPage({

return (
<div className="space-y-6">
<AdminBreadcrumbs
items={[
{ label: 'Admin', href: '/admin' },
{ label: 'Requests', href: '/admin/requests' },
{ label: 'Request Detail' },
]}
/>

{/* Back link */}
<Link
href="/admin/requests"
Expand Down Expand Up @@ -328,6 +337,7 @@ export default async function AdminRequestDetailPage({
<AssignTutorForm
requestId={request.id}
requestTimezone={request.timezone}
requestAvailabilityWindows={availWindows}
eligibleTutors={eligibleTutors}
/>
) : request.status === 'matched' || request.status === 'active' ? (
Expand Down
66 changes: 57 additions & 9 deletions app/admin/requests/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createAdminClient } from '@/lib/supabase/admin'
import { STATUS_COLOURS, STATUS_LABELS, LEVEL_LABELS } from '@/lib/utils/request'
import { RequestFilters } from './RequestFilters'
import { AdminPagination, PAGE_SIZE } from '@/components/AdminPagination'
import { normalizeAdminRequestSearch } from '@/lib/admin/request-search'
import type { Database } from '@/lib/supabase/database.types'

type RequestStatusEnum = Database['public']['Enums']['request_status_enum']
Expand Down Expand Up @@ -43,10 +44,12 @@ type FilterStatus = 'all' | (typeof ALL_STATUSES)[number]
export default async function AdminRequestsPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; subject?: string; level?: string; page?: string }>
searchParams: Promise<{ status?: string; subject?: string; level?: string; q?: string; page?: string }>
}) {
const { status, subject, level, page } = await searchParams
const { status, subject, level, q, page } = await searchParams
const activeStatus: FilterStatus = ALL_STATUSES.includes(status ?? '') ? status! : 'all'
const activeSearch = typeof q === 'string' ? q.trim() : ''
const normalizedSearch = normalizeAdminRequestSearch(activeSearch)
const currentPage = Math.max(1, parseInt(page ?? '1', 10) || 1)
const from = (currentPage - 1) * PAGE_SIZE
const to = from + PAGE_SIZE - 1
Expand Down Expand Up @@ -79,16 +82,60 @@ export default async function AdminRequestsPage({
dataQuery = dataQuery.eq('level', level as LevelEnum)
}

const subjectsQuery = admin.from('subjects').select('id, name').eq('active', true).order('sort_order')

// Apply text search at DB level
// For each token, find matching user IDs (display_name) and subject IDs (name),
// then OR-filter across for_student_name, created_by_user_id, and subject_id.
if (normalizedSearch) {
const rawTokens = normalizedSearch.split(' ').filter(Boolean)
// Sanitize: remove PostgREST or()-syntax metacharacters, then escape ILIKE wildcards
const safeTokens = rawTokens
.map((t) => t.replace(/[(),"]/g, '').replace(/[%_]/g, '\\$&'))
.filter(Boolean)

if (safeTokens.length > 0) {
// Run all token lookups in parallel (2 queries per token, all concurrent)
const lookups = await Promise.all(
safeTokens.flatMap((token) => [
admin.from('user_profiles').select('user_id').ilike('display_name', `%${token}%`),
admin.from('subjects').select('id').ilike('name', `%${token}%`),
]),
)

// Apply per-token AND filters — each token must match at least one field
for (let i = 0; i < safeTokens.length; i++) {
const token = safeTokens[i]
const { data: matchingUsers } = lookups[i * 2] as { data: { user_id: string }[] | null }
const { data: matchingSubjects } = lookups[i * 2 + 1] as { data: { id: number }[] | null }

const userIds = matchingUsers?.map((u) => u.user_id) ?? []
const subjectIds = matchingSubjects?.map((s) => s.id) ?? []

const orParts: string[] = [`for_student_name.ilike.%${token}%`]
if (userIds.length > 0) orParts.push(`created_by_user_id.in.(${userIds.join(',')})`)
if (subjectIds.length > 0) orParts.push(`subject_id.in.(${subjectIds.join(',')})`)

countQuery = countQuery.or(orParts.join(','))
dataQuery = dataQuery.or(orParts.join(','))
}
}
}

dataQuery = dataQuery.range(from, to)

const [{ count: totalCount }, { data: requestsData }, { data: subjectsData }] = await Promise.all([
let requests: RequestRow[] = []
let totalCount = 0
let subjects: { id: number; name: string }[] = []

const [{ count }, { data: requestsData }, { data: subjectsData }] = await Promise.all([
countQuery,
dataQuery,
admin.from('subjects').select('id, name').eq('active', true).order('sort_order'),
subjectsQuery,
])

const requests = (requestsData ?? []) as unknown as RequestRow[]
const subjects = (subjectsData ?? []) as { id: number; name: string }[]
totalCount = count ?? 0
requests = (requestsData ?? []) as unknown as RequestRow[]
subjects = (subjectsData ?? []) as { id: number; name: string }[]

const statusLinks: { label: string; value: FilterStatus }[] = [
{ label: 'All', value: 'all' },
Expand All @@ -102,7 +149,7 @@ export default async function AdminRequestsPage({
]

function buildStatusHref(newStatus: FilterStatus) {
const qs = Object.entries({ status: newStatus !== 'all' ? newStatus : undefined, subject, level })
const qs = Object.entries({ status: newStatus !== 'all' ? newStatus : undefined, subject, level, q: activeSearch || undefined })
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => `${k}=${encodeURIComponent(v!)}`)
.join('&')
Expand All @@ -114,7 +161,7 @@ export default async function AdminRequestsPage({
<div className="flex items-center justify-between">
<h1 className="text-3xl font-black uppercase tracking-tighter text-[#121212]">Requests</h1>
<p className="text-sm text-[#121212]/60">
{totalCount ?? requests.length} request{(totalCount ?? requests.length) !== 1 ? 's' : ''}
{totalCount} request{totalCount !== 1 ? 's' : ''}
</p>
</div>

Expand All @@ -141,6 +188,7 @@ export default async function AdminRequestsPage({
activeStatus={activeStatus}
activeSubject={subject}
activeLevel={level}
activeSearch={activeSearch}
/>

{/* Table */}
Expand Down
Loading
Loading