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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,14 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi
| **Admin: mark payment rejected** | ✅ Updates `payments.status → rejected` with optional rejection note, writes audit log |
| **DB: packages + payments tables** | ✅ `supabase/migrations/20260224000001_create_packages_payments.sql` — packages (tier_sessions 8/12/20, start/end date, sessions_total/used, status), payments (amount_pkr, method, reference, proof_path, rejection_note, verified_by/at), audit_logs; all with RLS |
| **Pricing config** | ✅ `lib/config/pricing.ts` — `PACKAGES` array (8/12/20 tiers, PKR prices, typicalFrequency) + `PAYMENT_INSTRUCTIONS` (bank details, reference format) |
| Sessions | 🚧 Coming in E6–E10 |
| **Tutor application form** | ✅ `app/tutor/profile/page.tsx` — tutor can fill in bio, timezone, subjects × levels (O/A checkboxes), weekly availability grid; saves to `tutor_profiles`, `tutor_subjects`, `tutor_availability`; shows pending/approved status badge |
| **Admin: tutor directory** | ✅ `app/admin/tutors/page.tsx` — lists all tutors with status, subjects, levels, timezone; filter by status (pending/approved), subject, level; Approve and Revoke buttons |
| **Admin: tutor detail page** | ✅ `app/admin/tutors/[id]/page.tsx` — full tutor profile including bio, all subjects × levels, availability windows, WhatsApp number; approve/revoke controls |
| **Tutor approval workflow** | ✅ `app/admin/tutors/actions.ts` — `approveTutor` sets `approved = true`; `revokeTutorApproval` sets `approved = false`; both write audit log entries |
| **DB: tutor tables** | ✅ `supabase/migrations/20260224000002_create_tutor_tables.sql` — `tutor_profiles` (approved, bio, timezone), `tutor_subjects` (subject_id × level per tutor), `tutor_availability` (JSONB windows); RLS policies for all three tables |
| **Tutor Zod schema** | ✅ `lib/validators/tutor.ts` — validates bio (min 50 chars), timezone, subjects array, availability windows |
| **Matching query helper** | ✅ `lib/services/matching.ts` — `fetchApprovedTutors()` shared query filtered to `approved = true`; ready for E7 matching screen |
| Sessions | 🚧 Coming in E7–E10 |

---

Expand Down Expand Up @@ -211,6 +218,7 @@ Recommended workflow
| `20260223000006_user_profiles_insert_rls.sql` | Adds INSERT policy on `user_profiles` so authenticated users can upsert their own row during profile setup (safety net if trigger row is absent). |
| `20260223000007_create_requests_table.sql` | `requests` table with all fields from the data model; indexes on `(status, created_at desc)` and `created_by_user_id`; `updated_at` trigger; 4 RLS policies (creator insert, creator/admin select, creator update limited to `new`/`payment_pending`, admin update). |
| `20260224000001_create_packages_payments.sql` | `packages` table (tier_sessions 8/12/20, start/end date, sessions_total/used, status enum, updated_at trigger, 3 RLS policies); `payments` table (amount_pkr, method, reference, proof_path, rejection_note, status enum, verified_by/at, updated_at trigger, 4 RLS policies); `audit_logs` table for admin payment actions. |
| `20260224000002_create_tutor_tables.sql` | `tutor_profiles` (approved bool default false, bio, timezone, updated_at trigger); `tutor_subjects` (tutor × subject × level, composite PK); `tutor_availability` (JSONB windows array, updated_at trigger); RLS policies for all three tables — tutors manage own rows, admins read/update all. |

> **Supabase Dashboard settings required for auth** (after running migrations):
>
Expand Down
6 changes: 6 additions & 0 deletions app/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ export default async function AdminLayout({
>
Payments
</Link>
<Link
href="/admin/tutors"
className="hover:text-zinc-900 dark:hover:text-zinc-100"
>
Tutors
</Link>
</nav>
</div>
<form action="/auth/sign-out" method="post">
Expand Down
63 changes: 63 additions & 0 deletions app/admin/tutors/TutorActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// E6 T6.2 T6.4: Client components for admin tutor approval/revoke buttons
// Closes #41 #43

'use client'

import { useActionState } from 'react'
import { approveTutor, revokeTutorApproval } from './actions'

type ActionResult = { error?: string } | undefined

async function approveAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
const tutorUserId = formData.get('tutorUserId') as string
return approveTutor(tutorUserId)
}

async function revokeAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
const tutorUserId = formData.get('tutorUserId') as string
return revokeTutorApproval(tutorUserId)
}

export function ApproveButton({ tutorUserId }: { tutorUserId: string }) {
const [state, formAction, isPending] = useActionState(approveAction, undefined)
return (
<form action={formAction}>
<input type="hidden" name="tutorUserId" value={tutorUserId} />
{state?.error && (
<p className="mb-1 text-xs text-red-600 dark:text-red-400">{state.error}</p>
)}
<button
type="submit"
disabled={isPending}
className="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-700 disabled:opacity-60"
>
{isPending ? 'Approving…' : '✅ Approve'}
</button>
</form>
)
}

export function RevokeButton({ tutorUserId }: { tutorUserId: string }) {
const [state, formAction, isPending] = useActionState(revokeAction, undefined)
return (
<form action={formAction}>
<input type="hidden" name="tutorUserId" value={tutorUserId} />
{state?.error && (
<p className="mb-1 text-xs text-red-600 dark:text-red-400">{state.error}</p>
)}
<button
type="submit"
disabled={isPending}
className="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-60"
>
{isPending ? 'Revoking…' : '❌ Revoke'}
</button>
</form>
)
}
71 changes: 71 additions & 0 deletions app/admin/tutors/TutorFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// E6 T6.4: Client component for tutor directory filter selects
// Closes #43

'use client'

type Subject = { id: number; name: string }

type TutorFiltersProps = {
subjects: Subject[]
activeSubject: string | undefined
activeLevel: string | undefined
activeStatus: string
}

export function TutorFilters({
subjects,
activeSubject,
activeLevel,
activeStatus,
}: TutorFiltersProps) {
function buildHref(params: Record<string, string | undefined>): string {
const merged: Record<string, string | undefined> = {
status: activeStatus,
subject: activeSubject,
level: activeLevel,
...params,
}
const qs = Object.entries(merged)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => `${k}=${encodeURIComponent(v!)}`)
.join('&')
return `/admin/tutors${qs ? `?${qs}` : ''}`
}

return (
<div className="flex flex-wrap gap-3">
{/* Subject filter */}
<select
value={activeSubject ?? ''}
onChange={(e) => {
const v = e.target.value
window.location.href = buildHref({ subject: v || undefined })
}}
className="rounded-lg border border-zinc-300 px-2 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100"
aria-label="Filter by subject"
>
<option value="">All subjects</option>
{subjects.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
</option>
))}
</select>

{/* Level filter */}
<select
value={activeLevel ?? ''}
onChange={(e) => {
const v = e.target.value
window.location.href = buildHref({ level: v || undefined })
}}
className="rounded-lg border border-zinc-300 px-2 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100"
aria-label="Filter by level"
>
<option value="">All levels</option>
<option value="o_levels">O Levels</option>
<option value="a_levels">A Levels</option>
</select>
</div>
)
}
189 changes: 189 additions & 0 deletions app/admin/tutors/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// E6 T6.3 T6.4 S6.2: Admin tutor detail page — full profile view with approve/revoke
// Closes #42 #43 #39

export const dynamic = 'force-dynamic'

import { notFound } from 'next/navigation'
import Link from 'next/link'
import { createAdminClient } from '@/lib/supabase/admin'
import { ApproveButton, RevokeButton } from '../TutorActions'

const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']

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

function formatTime(t: string): string {
const [h, m] = t.split(':').map(Number)
const period = h < 12 ? 'AM' : 'PM'
const hour = h % 12 === 0 ? 12 : h % 12
return `${hour}:${m.toString().padStart(2, '0')} ${period}`
}

export default async function AdminTutorDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const admin = createAdminClient()

const { data: tutorData } = await admin
.from('tutor_profiles')
.select(
`tutor_user_id, approved, bio, timezone, created_at, updated_at,
user_profiles!tutor_user_id ( display_name, whatsapp_number ),
tutor_subjects ( subject_id, level, subjects ( name ) ),
tutor_availability ( windows )`
)
.eq('tutor_user_id', id)
.maybeSingle()

if (!tutorData) notFound()

const tutor = tutorData as unknown as {
tutor_user_id: string
approved: boolean
bio: string | null
timezone: string
created_at: string
updated_at: string
user_profiles: { display_name: string; whatsapp_number: string | null } | null
tutor_subjects: { subject_id: number; level: string; subjects: { name: string } | null }[]
tutor_availability: { windows: AvailWindow[] } | null
}

const profile = tutor.user_profiles
const windows = tutor.tutor_availability?.windows ?? []

// Group subjects by name
const subjectMap = new Map<string, string[]>()
for (const s of tutor.tutor_subjects) {
const name = s.subjects?.name ?? `Subject ${s.subject_id}`
const lvl = s.level === 'o_levels' ? 'O Levels' : 'A Levels'
const existing = subjectMap.get(name) ?? []
existing.push(lvl)
subjectMap.set(name, existing)
}

const appliedDate = new Date(tutor.created_at).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
})

return (
<div className="mx-auto max-w-2xl space-y-6">
{/* Back link */}
<Link
href="/admin/tutors"
className="inline-flex items-center gap-1 text-sm text-indigo-600 hover:underline dark:text-indigo-400"
>
← Back to Tutors
</Link>

<div className="rounded-2xl bg-white px-6 py-8 shadow-sm dark:bg-zinc-900">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-xl font-bold text-zinc-900 dark:text-zinc-50">
{profile?.display_name ?? '—'}
</h1>
<p className="mt-0.5 text-sm text-zinc-500">Applied {appliedDate}</p>
</div>
<div className="flex items-center gap-3">
{tutor.approved ? (
<>
<span className="inline-flex items-center rounded-full bg-emerald-100 px-3 py-1 text-sm font-semibold text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
✅ Approved
</span>
<RevokeButton tutorUserId={tutor.tutor_user_id} />
</>
) : (
<>
<span className="inline-flex items-center rounded-full bg-yellow-100 px-3 py-1 text-sm font-semibold text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400">
⏳ Pending approval
</span>
<ApproveButton tutorUserId={tutor.tutor_user_id} />
</>
)}
</div>
</div>

<hr className="my-6 border-zinc-200 dark:border-zinc-700" />

{/* Contact */}
<section className="space-y-2">
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500">
Contact
</h2>
<p className="text-sm text-zinc-700 dark:text-zinc-300">
<span className="font-medium">WhatsApp:</span>{' '}
{profile?.whatsapp_number ?? (
<span className="italic text-zinc-400">not provided</span>
)}
</p>
<p className="text-sm text-zinc-700 dark:text-zinc-300">
<span className="font-medium">Timezone:</span> {tutor.timezone}
</p>
</section>

<hr className="my-6 border-zinc-200 dark:border-zinc-700" />

{/* Bio */}
<section className="space-y-2">
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500">Bio</h2>
{tutor.bio ? (
<p className="whitespace-pre-wrap text-sm text-zinc-700 dark:text-zinc-300">
{tutor.bio}
</p>
) : (
<p className="italic text-sm text-zinc-400">No bio provided.</p>
)}
</section>

<hr className="my-6 border-zinc-200 dark:border-zinc-700" />

{/* Subjects */}
<section className="space-y-2">
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500">
Subjects &amp; Levels
</h2>
{subjectMap.size === 0 ? (
<p className="italic text-sm text-zinc-400">No subjects selected.</p>
) : (
<ul className="space-y-1">
{Array.from(subjectMap.entries()).map(([name, levels]) => (
<li key={name} className="text-sm text-zinc-700 dark:text-zinc-300">
<span className="font-medium">{name}</span> — {levels.join(', ')}
</li>
))}
</ul>
)}
</section>

<hr className="my-6 border-zinc-200 dark:border-zinc-700" />

{/* Availability */}
<section className="space-y-2">
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500">
Availability
</h2>
{windows.length === 0 ? (
<p className="italic text-sm text-zinc-400">No availability set.</p>
) : (
<ul className="space-y-1">
{windows
.sort((a, b) => a.day - b.day || a.start.localeCompare(b.start))
.map((w, i) => (
<li key={i} className="text-sm text-zinc-700 dark:text-zinc-300">
<span className="font-medium">{DAY_NAMES[w.day]}:</span>{' '}
{formatTime(w.start)} – {formatTime(w.end)}
</li>
))}
</ul>
)}
</section>
</div>
</div>
)
}
Loading