Skip to content
Open
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
28 changes: 21 additions & 7 deletions src/app/actions/admin-enroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,25 @@

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { headers } from 'next/headers';
import { requireAdmin } from '@/lib/auth';
import { auditLog } from '@/lib/admin-audit';
import { grantManualEnrollment } from '@/lib/enrollment';
import { PaymentMethodValues } from '@/lib/enums';
import type { ActionResult } from '@/lib/validation';

const manualEnrollSchema = z.object({
email: z.string().trim().toLowerCase().email('Enter a valid email address.'),
name: z.string().trim().max(100).optional(),
pricingTierId: z.string().min(1, 'Pick a pricing tier.'),
payment: z
.object({
method: z.enum(PaymentMethodValues as [string, ...string[]], {
message: 'Pick a payment method.',
}),
amountPhp: z.number().int().positive('Enter the amount paid.'),
reference: z.string().trim().max(200).optional(),
})
.optional(),
});

export interface ManualEnrollActionData {
Expand All @@ -29,12 +39,13 @@ export interface ManualEnrollActionData {
tierName: string;
enrolledCount: number;
alreadyEnrolledCount: number;
paymentRecorded: boolean;
}

export async function manualEnrollAction(
input: z.infer<typeof manualEnrollSchema>,
): Promise<ActionResult<ManualEnrollActionData>> {
await requireAdmin();
const actor = await requireAdmin();

const parsed = manualEnrollSchema.safeParse(input);
if (!parsed.success) {
Expand All @@ -45,17 +56,19 @@ export async function manualEnrollAction(
}

try {
const heads = await headers();
const result = await grantManualEnrollment({
email: parsed.data.email,
name: parsed.data.name || null,
pricingTierId: parsed.data.pricingTierId,
payment: parsed.data.payment,
audit: {
actorId: actor.id,
ipAddress: heads.get('x-forwarded-for') ?? heads.get('x-real-ip'),
userAgent: heads.get('user-agent'),
},
});

await auditLog({
action: 'MANUAL_ENROLL',
entityType: 'User',
entityId: result.userId,
});
revalidatePath('/admin/users');
revalidatePath(`/admin/users/${result.userId}`);

Expand All @@ -77,6 +90,7 @@ export async function manualEnrollAction(
tierName: result.tierName,
enrolledCount: result.enrolledCourseIds.length,
alreadyEnrolledCount: result.alreadyEnrolledCourseIds.length,
paymentRecorded: result.paymentRecorded,
},
};
} catch (err) {
Expand Down
108 changes: 107 additions & 1 deletion src/app/admin/enroll/EnrollForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,29 @@ import {
manualEnrollAction,
type ManualEnrollActionData,
} from '@/app/actions/admin-enroll';
import { PaymentMethod, PaymentMethodValues } from '@/lib/enums';
import styles from './enroll.module.css';

interface TierOption {
id: string;
name: string;
priceLabel: string;
/** Centavos. */
pricePhp: number;
courseCount: number;
}

const PAYMENT_METHOD_LABELS: Record<string, string> = {
[PaymentMethod.GCASH]: 'GCash',
[PaymentMethod.MAYA]: 'Maya',
[PaymentMethod.GRABPAY]: 'GrabPay',
[PaymentMethod.CREDIT_CARD]: 'Credit card',
[PaymentMethod.DEBIT_CARD]: 'Debit card',
[PaymentMethod.BANK_TRANSFER]: 'Bank transfer',
[PaymentMethod.OTC]: 'Over-the-counter',
[PaymentMethod.OTHER]: 'Other',
};

export function EnrollForm({
defaultEmail,
tiers,
Expand All @@ -26,10 +40,33 @@ export function EnrollForm({
const [email, setEmail] = useState(defaultEmail);
const [name, setName] = useState('');
const [pricingTierId, setPricingTierId] = useState(tiers[0]?.id ?? '');
const [recordPayment, setRecordPayment] = useState(false);
const [paymentMethod, setPaymentMethod] = useState<string>(
PaymentMethodValues[0] ?? '',
);
const [amountPhpInput, setAmountPhpInput] = useState('');
const [reference, setReference] = useState('');
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ManualEnrollActionData | null>(null);
const [copied, setCopied] = useState(false);

function selectedTierPriceLabel(tierId: string): string {
const tier = tiers.find((t) => t.id === tierId);
return tier ? (tier.pricePhp / 100).toFixed(2) : '';
}

function handleTierChange(tierId: string) {
setPricingTierId(tierId);
if (recordPayment) setAmountPhpInput(selectedTierPriceLabel(tierId));
}

function handleRecordPaymentToggle(checked: boolean) {
setRecordPayment(checked);
if (checked && !amountPhpInput) {
setAmountPhpInput(selectedTierPriceLabel(pricingTierId));
}
}

function handleSubmit() {
setError(null);
setResult(null);
Expand All @@ -38,11 +75,27 @@ export function EnrollForm({
setError('Enter the student email.');
return;
}

let payment: { method: string; amountPhp: number; reference?: string } | undefined;
if (recordPayment) {
const pesos = Number(amountPhpInput);
if (!amountPhpInput || Number.isNaN(pesos) || pesos <= 0) {
setError('Enter the amount paid.');
return;
}
payment = {
method: paymentMethod,
amountPhp: Math.round(pesos * 100),
reference: reference.trim() || undefined,
};
}

startTransition(async () => {
const res = await manualEnrollAction({
email,
name: name.trim() || undefined,
pricingTierId,
payment,
});
if (!res.success) {
setError(res.error);
Expand Down Expand Up @@ -92,7 +145,7 @@ export function EnrollForm({
<span>Pricing tier</span>
<select
value={pricingTierId}
onChange={(e) => setPricingTierId(e.target.value)}
onChange={(e) => handleTierChange(e.target.value)}
className={styles.select}
>
{tiers.map((tier) => (
Expand All @@ -104,6 +157,58 @@ export function EnrollForm({
</select>
</label>

<label className={styles.checkboxRow}>
<input
type="checkbox"
checked={recordPayment}
onChange={(e) => handleRecordPaymentToggle(e.target.checked)}
/>
<span>Record a payment for bookkeeping (paid outside the platform)</span>
</label>

{recordPayment && (
<div className={styles.paymentFields}>
<label className={styles.field}>
<span>Payment method</span>
<select
value={paymentMethod}
onChange={(e) => setPaymentMethod(e.target.value)}
className={styles.select}
>
{PaymentMethodValues.map((method) => (
<option key={method} value={method}>
{PAYMENT_METHOD_LABELS[method] ?? method}
</option>
))}
</select>
</label>

<label className={styles.field}>
<span>Amount paid (₱)</span>
<input
type="number"
min="0"
step="0.01"
value={amountPhpInput}
onChange={(e) => setAmountPhpInput(e.target.value)}
className={styles.input}
/>
</label>

<label className={styles.field}>
<span>Reference / note (optional)</span>
<input
type="text"
value={reference}
onChange={(e) => setReference(e.target.value)}
placeholder="e.g. GCash ref #123456789"
className={styles.input}
autoComplete="off"
/>
</label>
</div>
)}

<Button onClick={handleSubmit} loading={isPending} variant="primary">
Enroll student
</Button>
Expand All @@ -120,6 +225,7 @@ export function EnrollForm({
{result.alreadyEnrolledCount > 0
? ` (${result.alreadyEnrolledCount} already active)`
: ''}
{result.paymentRecorded ? ' · payment recorded' : ''}
</p>

{result.claimUrl ? (
Expand Down
19 changes: 19 additions & 0 deletions src/app/admin/enroll/enroll.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,25 @@
}
.select { cursor: pointer; }

.checkboxRow {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
}

.paymentFields {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
}

.error {
color: var(--color-danger, #dc3545);
font-size: var(--text-sm);
Expand Down
1 change: 1 addition & 0 deletions src/app/admin/enroll/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export default async function AdminEnrollPage({
id: t.id,
name: t.name,
priceLabel: formatPhp(t.pricePhp),
pricePhp: t.pricePhp,
courseCount: t.courses.length,
}))}
/>
Expand Down
7 changes: 5 additions & 2 deletions src/app/admin/users/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,16 @@ export default async function UserDetailPage({
<div key={p.id} className={styles.listItem}>
<div className={styles.itemTitle}>
{formatPhp(p.amountPhp)}{' '}
<Badge variant={p.status === 'PAID' ? 'success' : 'default'}>
<Badge variant={p.status === 'COMPLETED' ? 'success' : 'default'}>
{p.status}
</Badge>
</div>
<div className={styles.itemMeta}>
{p.pricingTier?.name} · {formatDate(p.createdAt)}
{p.pricingTier?.name} · {p.method} · {formatDate(p.createdAt)}
</div>
{p.metadata && (
<div className={styles.itemMeta}>{p.metadata}</div>
)}
</div>
))}
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/components/ui/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
Download,
Video,
Circle,
UserPlus,
} from '@phosphor-icons/react/dist/ssr';
import clsx from 'clsx';
import styles from './Icon.module.css';
Expand Down Expand Up @@ -113,7 +114,8 @@ export type PhosphorIconName =
| 'ArrowRight'
| 'Download'
| 'Video'
| 'Circle';
| 'Circle'
| 'UserPlus';

const ICON_MAP: Record<PhosphorIconName, React.ComponentType<PhosphorIconProps>> = {
House,
Expand Down Expand Up @@ -149,6 +151,7 @@ const ICON_MAP: Record<PhosphorIconName, React.ComponentType<PhosphorIconProps>>
Download,
Video,
Circle,
UserPlus,
};

export function Icon({
Expand Down
1 change: 1 addition & 0 deletions src/components/ui/NavSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface NavItem {
const ADMIN_NAV_ITEMS: NavItem[] = [
{ href: '/admin', label: 'Dashboard', icon: 'House' },
{ href: '/admin/users', label: 'Users', icon: 'User' },
{ href: '/admin/enroll', label: 'Enroll student', icon: 'UserPlus' },
{ href: '/admin/courses', label: 'Courses', icon: 'BookOpen' },
{ href: '/admin/refunds', label: 'Refunds', icon: 'Receipt' },
{ href: '/admin/tool-scenarios', label: 'Tool scenarios', icon: 'List' },
Expand Down
Loading