) : request.status === 'matched' || request.status === 'active' ? (
diff --git a/app/admin/requests/page.tsx b/app/admin/requests/page.tsx
index 6599159..4ca44c4 100644
--- a/app/admin/requests/page.tsx
+++ b/app/admin/requests/page.tsx
@@ -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']
@@ -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
@@ -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' },
@@ -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('&')
@@ -114,7 +161,7 @@ export default async function AdminRequestsPage({
@@ -141,6 +188,7 @@ export default async function AdminRequestsPage({
activeStatus={activeStatus}
activeSubject={subject}
activeLevel={level}
+ activeSearch={activeSearch}
/>
{/* Table */}
diff --git a/app/admin/tutors/[id]/page.tsx b/app/admin/tutors/[id]/page.tsx
index 432fdbf..462ee7c 100644
--- a/app/admin/tutors/[id]/page.tsx
+++ b/app/admin/tutors/[id]/page.tsx
@@ -8,6 +8,7 @@ import Link from 'next/link'
import { createAdminClient } from '@/lib/supabase/admin'
import { ApproveButton, RevokeButton } from '../TutorActions'
import { WhatsAppLink } from '@/components/WhatsAppLink'
+import { AdminBreadcrumbs } from '@/components/admin/AdminBreadcrumbs'
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
@@ -74,6 +75,14 @@ export default async function AdminTutorDetailPage({
return (
+
+
{/* Back link */}
- PKR {payment.amount_pkr.toLocaleString()}
+ {formatPkr(payment.amount_pkr)}
{pkgConfig && (
{pkgConfig.typicalFrequency} ยท 60 min/session
diff --git a/app/dashboard/packages/new/page.tsx b/app/dashboard/packages/new/page.tsx
index a06d791..fb89f9c 100644
--- a/app/dashboard/packages/new/page.tsx
+++ b/app/dashboard/packages/new/page.tsx
@@ -6,6 +6,7 @@
import { Suspense, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { PACKAGES } from '@/lib/config/pricing'
+import { formatPkr } from '@/lib/utils/currency'
function NewPackageContent() {
const router = useRouter()
@@ -94,7 +95,7 @@ function NewPackageContent() {
{pkg.typicalFrequency}
- PKR {pkg.pricePerMonthPkr.toLocaleString()}
+ {formatPkr(pkg.pricePerMonthPkr)}
{pkg.description}
diff --git a/app/page.tsx b/app/page.tsx
index feadb34..bf3c5c3 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -4,6 +4,7 @@ import { WhatsAppCTA } from '@/components/WhatsAppCTA'
import { createClient } from '@/lib/supabase/server'
import { signOut } from '@/app/auth/actions'
import { PACKAGES as PACKAGE_CONFIGS } from '@/lib/config/pricing'
+import { formatPkr } from '@/lib/utils/currency'
const SUBJECTS = [
'Mathematics',
@@ -54,7 +55,7 @@ const LANDING_PACKAGES = PACKAGE_CONFIGS.map((pkg) => {
...(cardStyle ?? {}),
sessions: pkg.sessionsPerMonth,
frequency: pkg.typicalFrequency,
- price: `PKR ${pkg.pricePerMonthPkr.toLocaleString('en-PK')}`,
+ price: formatPkr(pkg.pricePerMonthPkr),
description: cardStyle?.description ?? pkg.description,
highlight: cardStyle?.highlight ?? pkg.tier === 12,
accentColor: cardStyle?.accentColor ?? '#1040C0',
diff --git a/components/admin/AdminBreadcrumbs.tsx b/components/admin/AdminBreadcrumbs.tsx
new file mode 100644
index 0000000..197aba4
--- /dev/null
+++ b/components/admin/AdminBreadcrumbs.tsx
@@ -0,0 +1,36 @@
+import Link from 'next/link'
+
+export type AdminBreadcrumbItem = {
+ label: string
+ href?: string
+}
+
+export function AdminBreadcrumbs({ items }: { items: AdminBreadcrumbItem[] }) {
+ return (
+
+ )
+}
diff --git a/components/admin/__tests__/AdminBreadcrumbs.test.ts b/components/admin/__tests__/AdminBreadcrumbs.test.ts
new file mode 100644
index 0000000..437fc49
--- /dev/null
+++ b/components/admin/__tests__/AdminBreadcrumbs.test.ts
@@ -0,0 +1,23 @@
+import React from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, test } from 'vitest'
+
+import { AdminBreadcrumbs } from '@/components/admin/AdminBreadcrumbs'
+
+describe('AdminBreadcrumbs', () => {
+ test('renders deterministic admin return links without browser history', () => {
+ const html = renderToStaticMarkup(
+ React.createElement(AdminBreadcrumbs, {
+ items: [
+ { label: 'Admin', href: '/admin' },
+ { label: 'Requests', href: '/admin/requests' },
+ { label: 'Request Detail' },
+ ],
+ }),
+ )
+
+ expect(html).toContain('href="/admin"')
+ expect(html).toContain('href="/admin/requests"')
+ expect(html).toContain('Request Detail')
+ })
+})
diff --git a/docs/GAP_ANALYSIS.md b/docs/GAP_ANALYSIS.md
index ae7a25e..eb1947e 100644
--- a/docs/GAP_ANALYSIS.md
+++ b/docs/GAP_ANALYSIS.md
@@ -61,12 +61,12 @@
| **Severity** | Critical |
| **Status** | **RESOLVED** โ Migration `20260303000001_fix_double_increment_guard.sql` replaces the RPC with a guarded version that checks previous status before incrementing/decrementing. Also adds `decrement_sessions_used` helper. Local Supabase integration tests now verify double-submit stability, consuming/non-consuming transitions, service-role direct increments, and future-session blocking. |
-### B2. ~~Documentation says `p_package_id` but code uses `p_request_id`~~ โ
DONE
+### B2. ~~Documentation used an old package-id RPC example but code uses `p_request_id`~~ โ
DONE
| | |
|---|---|
| **Severity** | Important |
-| **Status** | **RESOLVED** โ `CLAUDE.md` updated to say `increment_sessions_used(p_request_id)`. |
+| **Status** | **RESOLVED** โ Contributor-facing RPC examples now use `increment_sessions_used(p_request_id)` where present, and a repo search confirms no stale package-id RPC parameter example remains. |
### B3. ~~Reschedule is admin-only; students have no self-service path~~ โ
DONE
diff --git a/docs/plan-CorvEd.md b/docs/plan-CorvEd.md
index de75481..b650c68 100644
--- a/docs/plan-CorvEd.md
+++ b/docs/plan-CorvEd.md
@@ -121,8 +121,8 @@ These are the items required before MVP v0.1 is declared done per the exit crite
### 3.2 Nice-to-Have for v0.1 (but not blockers)
-- [ ] **Tutor availability overlap matching** โ the current `fetchApprovedTutors` filters by subject and level but does not compare tutor availability_windows against the student's requested availability.
-- [ ] **Admin match detail: link to student and tutor profiles** โ the match detail page shows names but should link to the user management and tutor detail pages.
+- [x] **Tutor availability overlap matching** โ the admin assignment screen now shows overlap, no-overlap, and missing-availability hints using `lib/utils/availability.ts`; assignment remains manual.
+- [x] **Admin match detail: link to student and tutor profiles** โ match detail now links the requester into admin user search and the tutor to the tutor detail page.
- [x] **"What happens next" status banners** โ `components/dashboards/StatusBanner.tsx` created and integrated into student dashboard.
- [x] **Tutor no-show handling in admin** โ `/admin/sessions?status=no_show` now intentionally groups `no_show_student` and `no_show_tutor`, so analytics links land on the sessions requiring follow-up.
@@ -138,6 +138,8 @@ These are the items required before MVP v0.1 is declared done per the exit crite
- [x] **Empty states** โ friendly empty state messages on list pages.
- [x] **Error handling** โ toast notifications standardized via Sonner. Zod validation for admin actions.
- [x] **Reschedule cutoff warning** โ `RescheduleButton` already implements 24-hour cutoff.
+- [x] **Admin detail breadcrumbs** โ request, tutor, and match detail pages now expose deterministic admin breadcrumb return paths.
+- [x] **PKR formatting consistency** โ pricing and payment UI now use shared `formatPkr()` formatting.
### 4.2 Design System Application โ โ
DONE
@@ -152,7 +154,7 @@ The Bauhaus design system has been applied:
### 4.3 Operational Tooling
- [ ] **Admin WhatsApp copy buttons โ comprehensive coverage** โ extend to: 1-hour reminder, reschedule confirmation, no-show policy reminder on session pages.
-- [ ] **Filter and search improvements** โ add text search by student name/subject to admin requests page.
+- [x] **Filter and search improvements** โ admin requests now support text search by student/requester/subject and compose with status, subject, and level filters.
- [x] **Audit log privacy hygiene** โ audit detail writes use `sanitizeAuditDetails()` and the tutor session RPC redacts free-text notes before inserting into `audit_logs`.
### 4.4 Reliability & Error Handling โ MOSTLY DONE
diff --git a/lib/admin/__tests__/request-search.test.ts b/lib/admin/__tests__/request-search.test.ts
new file mode 100644
index 0000000..4efe152
--- /dev/null
+++ b/lib/admin/__tests__/request-search.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, test } from 'vitest'
+
+import { filterAdminRequestsBySearch, normalizeAdminRequestSearch } from '@/lib/admin/request-search'
+
+const requests = [
+ {
+ for_student_name: 'Amina Khan',
+ subjects: { name: 'Mathematics' },
+ user_profiles: { display_name: 'Sara Khan' },
+ },
+ {
+ for_student_name: null,
+ subjects: { name: 'Physics' },
+ user_profiles: { display_name: 'Bilal Ahmed' },
+ },
+]
+
+describe('normalizeAdminRequestSearch', () => {
+ test('trims and collapses repeated whitespace', () => {
+ expect(normalizeAdminRequestSearch(' amina math ')).toBe('amina math')
+ })
+})
+
+describe('filterAdminRequestsBySearch', () => {
+ test('matches student/requester names and subject text case-insensitively', () => {
+ expect(filterAdminRequestsBySearch(requests, 'amina')).toEqual([requests[0]])
+ expect(filterAdminRequestsBySearch(requests, 'sara')).toEqual([requests[0]])
+ expect(filterAdminRequestsBySearch(requests, 'PHY')).toEqual([requests[1]])
+ })
+
+ test('requires every search token to match the request search text', () => {
+ expect(filterAdminRequestsBySearch(requests, 'amina mathematics')).toEqual([requests[0]])
+ expect(filterAdminRequestsBySearch(requests, 'amina physics')).toEqual([])
+ })
+})
diff --git a/lib/admin/request-search.ts b/lib/admin/request-search.ts
new file mode 100644
index 0000000..9b163d6
--- /dev/null
+++ b/lib/admin/request-search.ts
@@ -0,0 +1,37 @@
+export type AdminRequestSearchRow = {
+ for_student_name?: string | null
+ subjects?: { name?: string | null } | null
+ user_profiles?: { display_name?: string | null } | null
+}
+
+export function normalizeAdminRequestSearch(query: string | null | undefined): string {
+ return (query ?? '').trim().replace(/\s+/g, ' ').toLowerCase()
+}
+
+function getSearchTokens(query: string | null | undefined): string[] {
+ return normalizeAdminRequestSearch(query).split(' ').filter(Boolean)
+}
+
+function getSearchText(request: AdminRequestSearchRow): string {
+ return [
+ request.for_student_name,
+ request.user_profiles?.display_name,
+ request.subjects?.name,
+ ]
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase()
+}
+
+export function filterAdminRequestsBySearch(
+ requests: T[],
+ query: string | null | undefined,
+): T[] {
+ const tokens = getSearchTokens(query)
+ if (tokens.length === 0) return requests
+
+ return requests.filter((request) => {
+ const searchText = getSearchText(request)
+ return tokens.every((token) => searchText.includes(token))
+ })
+}
diff --git a/lib/utils/__tests__/availability.test.ts b/lib/utils/__tests__/availability.test.ts
new file mode 100644
index 0000000..cd15cf0
--- /dev/null
+++ b/lib/utils/__tests__/availability.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, test } from 'vitest'
+
+import {
+ getAvailabilityOverlapSummary,
+ getOverlappingAvailabilityWindows,
+} from '@/lib/utils/availability'
+
+describe('getOverlappingAvailabilityWindows', () => {
+ test('returns same-day time intersections for structured availability windows', () => {
+ expect(
+ getOverlappingAvailabilityWindows(
+ [{ day: 1, start: '17:00', end: '19:00' }],
+ [{ day: 1, start: '18:00', end: '20:00' }],
+ ),
+ ).toEqual([{ day: 1, start: '18:00', end: '19:00' }])
+ })
+
+ test('ignores non-overlap and different-day windows', () => {
+ expect(
+ getOverlappingAvailabilityWindows(
+ [{ day: 1, start: '17:00', end: '18:00' }],
+ [
+ { day: 1, start: '18:00', end: '19:00' },
+ { day: 2, start: '17:30', end: '18:30' },
+ ],
+ ),
+ ).toEqual([])
+ })
+})
+
+describe('getAvailabilityOverlapSummary', () => {
+ test('distinguishes missing request and tutor availability from no overlap', () => {
+ expect(getAvailabilityOverlapSummary([], [{ day: 1, start: '17:00', end: '18:00' }])).toEqual({
+ status: 'missing_request',
+ overlaps: [],
+ })
+
+ expect(getAvailabilityOverlapSummary([{ day: 1, start: '17:00', end: '18:00' }], [])).toEqual({
+ status: 'missing_tutor',
+ overlaps: [],
+ })
+
+ expect(
+ getAvailabilityOverlapSummary(
+ [{ day: 1, start: '17:00', end: '18:00' }],
+ [{ day: 1, start: '19:00', end: '20:00' }],
+ ),
+ ).toEqual({
+ status: 'none',
+ overlaps: [],
+ })
+ })
+})
diff --git a/lib/utils/__tests__/currency.test.ts b/lib/utils/__tests__/currency.test.ts
new file mode 100644
index 0000000..463000a
--- /dev/null
+++ b/lib/utils/__tests__/currency.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, test } from 'vitest'
+
+import { formatPkr } from '@/lib/utils/currency'
+
+describe('formatPkr', () => {
+ test('formats whole PKR amounts with a stable launch-friendly prefix', () => {
+ expect(formatPkr(8000)).toBe('PKR 8,000')
+ expect(formatPkr(16000)).toBe('PKR 16,000')
+ })
+
+ test('keeps zero placeholder values readable', () => {
+ expect(formatPkr(0)).toBe('PKR 0')
+ })
+})
diff --git a/lib/utils/availability.ts b/lib/utils/availability.ts
new file mode 100644
index 0000000..d380f62
--- /dev/null
+++ b/lib/utils/availability.ts
@@ -0,0 +1,88 @@
+export type AvailabilityWindow = {
+ day: number
+ start: string
+ end: string
+}
+
+export type AvailabilityOverlapStatus =
+ | 'overlap'
+ | 'none'
+ | 'missing_request'
+ | 'missing_tutor'
+
+export type AvailabilityOverlapSummary = {
+ status: AvailabilityOverlapStatus
+ overlaps: AvailabilityWindow[]
+}
+
+function timeToMinutes(time: string): number | null {
+ const match = /^(\d{2}):(\d{2})$/.exec(time)
+ if (!match) return null
+
+ const hours = Number(match[1])
+ const minutes = Number(match[2])
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
+
+ return hours * 60 + minutes
+}
+
+function minutesToTime(minutes: number): string {
+ const hours = Math.floor(minutes / 60)
+ const mins = minutes % 60
+
+ return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`
+}
+
+export function getOverlappingAvailabilityWindows(
+ requestWindows: AvailabilityWindow[],
+ tutorWindows: AvailabilityWindow[],
+): AvailabilityWindow[] {
+ const overlaps: AvailabilityWindow[] = []
+
+ for (const requestWindow of requestWindows) {
+ const requestStart = timeToMinutes(requestWindow.start)
+ const requestEnd = timeToMinutes(requestWindow.end)
+ if (requestStart === null || requestEnd === null || requestEnd <= requestStart) continue
+
+ for (const tutorWindow of tutorWindows) {
+ if (requestWindow.day !== tutorWindow.day) continue
+
+ const tutorStart = timeToMinutes(tutorWindow.start)
+ const tutorEnd = timeToMinutes(tutorWindow.end)
+ if (tutorStart === null || tutorEnd === null || tutorEnd <= tutorStart) continue
+
+ const start = Math.max(requestStart, tutorStart)
+ const end = Math.min(requestEnd, tutorEnd)
+
+ if (end > start) {
+ overlaps.push({
+ day: requestWindow.day,
+ start: minutesToTime(start),
+ end: minutesToTime(end),
+ })
+ }
+ }
+ }
+
+ return overlaps.sort((a, b) => a.day - b.day || a.start.localeCompare(b.start))
+}
+
+export function getAvailabilityOverlapSummary(
+ requestWindows: AvailabilityWindow[],
+ tutorWindows: AvailabilityWindow[],
+): AvailabilityOverlapSummary {
+ if (requestWindows.length === 0) {
+ return { status: 'missing_request', overlaps: [] }
+ }
+
+ if (tutorWindows.length === 0) {
+ return { status: 'missing_tutor', overlaps: [] }
+ }
+
+ const overlaps = getOverlappingAvailabilityWindows(requestWindows, tutorWindows)
+
+ return {
+ status: overlaps.length > 0 ? 'overlap' : 'none',
+ overlaps,
+ }
+}
diff --git a/lib/utils/currency.ts b/lib/utils/currency.ts
new file mode 100644
index 0000000..ccef686
--- /dev/null
+++ b/lib/utils/currency.ts
@@ -0,0 +1,11 @@
+const pkrNumberFormatter = new Intl.NumberFormat('en-PK', {
+ maximumFractionDigits: 0,
+ minimumFractionDigits: 0,
+})
+
+export function formatPkr(amount: number | null | undefined): string {
+ const value = Number.isFinite(amount) ? Math.max(0, Math.round(amount ?? 0)) : 0
+ const formatted = pkrNumberFormatter.format(value)
+
+ return `PKR ${formatted}`
+}