diff --git a/backend/prisma/migrations/20260617120000_add_platform_settings/migration.sql b/backend/prisma/migrations/20260617120000_add_platform_settings/migration.sql new file mode 100644 index 0000000..e0a8a96 --- /dev/null +++ b/backend/prisma/migrations/20260617120000_add_platform_settings/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "platform_settings" ( + "id" INTEGER NOT NULL DEFAULT 1, + "stipend_month_1" DECIMAL(12,2) NOT NULL DEFAULT 30, + "stipend_month_2" DECIMAL(12,2) NOT NULL DEFAULT 50, + "stipend_month_3" DECIMAL(12,2) NOT NULL DEFAULT 100, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "platform_settings_pkey" PRIMARY KEY ("id") +); + +-- Seed the default singleton row +INSERT INTO "platform_settings" ("id") VALUES (1); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ce6d706..af18052 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -306,6 +306,17 @@ model Payment { @@map("payments") } +// Singleton platform-wide configuration. There is always exactly one row (id=1). +model PlatformSetting { + id Int @id @default(1) + stipendMonth1 Decimal @default(30) @map("stipend_month_1") @db.Decimal(12, 2) + stipendMonth2 Decimal @default(50) @map("stipend_month_2") @db.Decimal(12, 2) + stipendMonth3 Decimal @default(100) @map("stipend_month_3") @db.Decimal(12, 2) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz + + @@map("platform_settings") +} + // Per-student delivery settings, one row per user. A missing row means every // channel is on (see the defaults below) — rows are created lazily on first edit. model NotificationPreference { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 660f97b..03749e7 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,7 @@ import { NotificationsModule } from '@modules/notifications/notifications.module import { SubmissionsModule } from '@modules/submissions/submissions.module'; import { TasksModule } from '@modules/tasks/tasks.module'; import { UsersModule } from '@modules/users/users.module'; +import { SettingsModule } from '@modules/settings/settings.module'; @Module({ imports: [ @@ -34,6 +35,7 @@ import { UsersModule } from '@modules/users/users.module'; UsersModule, DashboardModule, NotificationsModule, + SettingsModule, ], providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }], }) diff --git a/backend/src/modules/account/account.controller.ts b/backend/src/modules/account/account.controller.ts index 80ecf4e..971efcd 100644 --- a/backend/src/modules/account/account.controller.ts +++ b/backend/src/modules/account/account.controller.ts @@ -109,36 +109,34 @@ export class AccountController { }; const hasSocials = Object.values(socialFields).some((v) => v !== undefined); + const SOCIALS_SELECT = { + linkedin: true, + twitter: true, + facebook: true, + github: true, + portfolio: true, + telegram: true, + whatsapp: true, + } as const; + const [updated, socials] = await Promise.all([ this.prisma.user.update({ where: { id: user.id }, data: userFields }), hasSocials - ? this.prisma.application - .updateMany({ where: { userId: user.id }, data: socialFields }) - .then(() => - this.prisma.application.findUnique({ - where: { userId: user.id }, - select: { - linkedin: true, - twitter: true, - facebook: true, - github: true, - portfolio: true, - telegram: true, - whatsapp: true, - }, - }), - ) - : this.prisma.application.findUnique({ + ? this.prisma.application.upsert({ where: { userId: user.id }, - select: { - linkedin: true, - twitter: true, - facebook: true, - github: true, - portfolio: true, - telegram: true, - whatsapp: true, + update: socialFields, + create: { + userId: user.id, + email: user.email, + firstName: user.name?.split(' ')[0] ?? '', + lastName: user.name?.split(' ').slice(1).join(' ') ?? '', + ...socialFields, }, + select: SOCIALS_SELECT, + }) + : this.prisma.application.findUnique({ + where: { userId: user.id }, + select: SOCIALS_SELECT, }), ]); diff --git a/backend/src/modules/dashboard/dashboard.service.ts b/backend/src/modules/dashboard/dashboard.service.ts index a280948..0b96f34 100644 --- a/backend/src/modules/dashboard/dashboard.service.ts +++ b/backend/src/modules/dashboard/dashboard.service.ts @@ -41,6 +41,7 @@ export interface MonthlyPayment { approvedThisMonth: number; paymentDate: string; eligible: boolean; + enrollmentMonth: number; } export interface StudentDashboard { @@ -118,7 +119,7 @@ export class DashboardService { typeBreakdown: [], weeklyActivity: this.buildWeeklyActivity([]), }, - monthlyPayment: this.buildMonthlyPayment([], new Set()), + monthlyPayment: this.buildMonthlyPayment([], new Set(), new Date()), }; } @@ -214,13 +215,18 @@ export class DashboardService { typeBreakdown, weeklyActivity: this.buildWeeklyActivity(mySubmissions), }, - monthlyPayment: this.buildMonthlyPayment(tasks, approvedIds), + monthlyPayment: this.buildMonthlyPayment( + tasks, + approvedIds, + selected.enrolledAt, + ), }; } private buildMonthlyPayment( tasks: { id: number; dueDate: Date | null }[], approvedIds: Set, + enrolledAt: Date, ): MonthlyPayment { const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); @@ -239,6 +245,9 @@ export class DashboardService { const approvedThisMonth = tasksThisMonth.filter((t) => approvedIds.has(t.id), ).length; + const monthsElapsed = + (now.getFullYear() - enrolledAt.getFullYear()) * 12 + + (now.getMonth() - enrolledAt.getMonth()); return { tasksThisMonth: tasksThisMonth.length, approvedThisMonth, @@ -246,6 +255,7 @@ export class DashboardService { eligible: tasksThisMonth.length > 0 && approvedThisMonth === tasksThisMonth.length, + enrollmentMonth: monthsElapsed + 1, }; } diff --git a/backend/src/modules/settings/dto/update-settings.dto.ts b/backend/src/modules/settings/dto/update-settings.dto.ts new file mode 100644 index 0000000..cb9f9f4 --- /dev/null +++ b/backend/src/modules/settings/dto/update-settings.dto.ts @@ -0,0 +1,18 @@ +import { IsNumber, IsOptional, Min } from 'class-validator'; + +export class UpdateSettingsDto { + @IsOptional() + @IsNumber() + @Min(0.01) + stipendMonth1?: number; + + @IsOptional() + @IsNumber() + @Min(0.01) + stipendMonth2?: number; + + @IsOptional() + @IsNumber() + @Min(0.01) + stipendMonth3?: number; +} diff --git a/backend/src/modules/settings/settings.controller.ts b/backend/src/modules/settings/settings.controller.ts new file mode 100644 index 0000000..e764007 --- /dev/null +++ b/backend/src/modules/settings/settings.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Get, Patch } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { Roles } from '@core/auth/roles.decorator'; +import { UpdateSettingsDto } from './dto/update-settings.dto'; +import { SettingsService, type SettingsDto } from './settings.service'; + +@ApiTags('settings') +@Controller('settings') +export class SettingsController { + constructor(private readonly service: SettingsService) {} + + @Get() + get(): Promise { + return this.service.get(); + } + + @Roles('admin') + @Patch() + update(@Body() dto: UpdateSettingsDto): Promise { + return this.service.update(dto); + } +} diff --git a/backend/src/modules/settings/settings.module.ts b/backend/src/modules/settings/settings.module.ts new file mode 100644 index 0000000..63e54df --- /dev/null +++ b/backend/src/modules/settings/settings.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { SettingsController } from './settings.controller'; +import { SettingsService } from './settings.service'; + +@Module({ + controllers: [SettingsController], + providers: [SettingsService], +}) +export class SettingsModule {} diff --git a/backend/src/modules/settings/settings.service.ts b/backend/src/modules/settings/settings.service.ts new file mode 100644 index 0000000..6ea0d37 --- /dev/null +++ b/backend/src/modules/settings/settings.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import type { PlatformSetting } from '@prisma/client'; +import { PrismaService } from '@core/database/prisma.service'; +import { UpdateSettingsDto } from './dto/update-settings.dto'; + +export interface SettingsDto { + stipendMonth1: string; + stipendMonth2: string; + stipendMonth3: string; +} + +@Injectable() +export class SettingsService { + constructor(private readonly prisma: PrismaService) {} + + async get(): Promise { + const row = await this.prisma.platformSetting.upsert({ + where: { id: 1 }, + update: {}, + create: { id: 1 }, + }); + return this.serialize(row); + } + + async update(dto: UpdateSettingsDto): Promise { + const row = await this.prisma.platformSetting.upsert({ + where: { id: 1 }, + update: { + ...(dto.stipendMonth1 != null && { stipendMonth1: dto.stipendMonth1 }), + ...(dto.stipendMonth2 != null && { stipendMonth2: dto.stipendMonth2 }), + ...(dto.stipendMonth3 != null && { stipendMonth3: dto.stipendMonth3 }), + }, + create: { + id: 1, + stipendMonth1: dto.stipendMonth1 ?? 30, + stipendMonth2: dto.stipendMonth2 ?? 50, + stipendMonth3: dto.stipendMonth3 ?? 100, + }, + }); + return this.serialize(row); + } + + private serialize(row: PlatformSetting): SettingsDto { + return { + stipendMonth1: row.stipendMonth1.toString(), + stipendMonth2: row.stipendMonth2.toString(), + stipendMonth3: row.stipendMonth3.toString(), + }; + } +} diff --git a/backend/test/unit/modules/account/account.controller.spec.ts b/backend/test/unit/modules/account/account.controller.spec.ts index f253082..4567970 100644 --- a/backend/test/unit/modules/account/account.controller.spec.ts +++ b/backend/test/unit/modules/account/account.controller.spec.ts @@ -28,7 +28,11 @@ const user = makeUser(); describe('AccountController', () => { let controller: AccountController; let prisma: { - application: { findUnique: jest.Mock; updateMany: jest.Mock }; + application: { + findUnique: jest.Mock; + updateMany: jest.Mock; + upsert: jest.Mock; + }; user: { update: jest.Mock }; enrollment: { findMany: jest.Mock }; }; @@ -39,6 +43,7 @@ describe('AccountController', () => { application: { findUnique: jest.fn().mockResolvedValue(null), updateMany: jest.fn().mockResolvedValue({ count: 1 }), + upsert: jest.fn().mockResolvedValue(null), }, user: { update: jest.fn().mockResolvedValue(makeUser()) }, enrollment: { findMany: jest.fn().mockResolvedValue([]) }, @@ -102,17 +107,25 @@ describe('AccountController', () => { expect(prisma.user.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 2 }, data: { name: 'Ada B' } }), ); - // No social field provided, so socials are not written. - expect(prisma.application.updateMany).not.toHaveBeenCalled(); + // No social field provided, so upsert is not called. + expect(prisma.application.upsert).not.toHaveBeenCalled(); }); - it('updates the application socials when a social field is present', async () => { + it('upserts the application socials when a social field is present', async () => { await controller.updateProfile(user, { name: 'Ada', github: 'https://github.com/ada', }); - expect(prisma.application.updateMany).toHaveBeenCalledTimes(1); + expect(prisma.application.upsert).toHaveBeenCalledTimes(1); + expect(prisma.application.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 2 }, + update: expect.objectContaining({ + github: 'https://github.com/ada', + }) as unknown, + }), + ); }); }); diff --git a/backend/test/unit/modules/dashboard/dashboard.service.spec.ts b/backend/test/unit/modules/dashboard/dashboard.service.spec.ts index 5153a8a..58e9260 100644 --- a/backend/test/unit/modules/dashboard/dashboard.service.spec.ts +++ b/backend/test/unit/modules/dashboard/dashboard.service.spec.ts @@ -116,7 +116,11 @@ describe('DashboardService', () => { it('aggregates task stats from the student submissions', async () => { prisma.enrollment.findMany.mockResolvedValue([ - { cohortId: 3, cohort: makeCohort({ id: 3, name: 'Spring' }) }, + { + cohortId: 3, + cohort: makeCohort({ id: 3, name: 'Spring' }), + enrolledAt: DATE, + }, ]); prisma.task.findMany.mockResolvedValue([ makeTask({ id: 1 }), @@ -149,7 +153,11 @@ describe('DashboardService', () => { it('counts a task with no submission as todo in the status breakdown', async () => { prisma.enrollment.findMany.mockResolvedValue([ - { cohortId: 3, cohort: makeCohort({ id: 3, name: 'Spring' }) }, + { + cohortId: 3, + cohort: makeCohort({ id: 3, name: 'Spring' }), + enrolledAt: DATE, + }, ]); prisma.task.findMany.mockResolvedValue([ makeTask({ id: 1 }), @@ -171,8 +179,16 @@ describe('DashboardService', () => { it('falls back to the most recent enrollment for an unknown cohortId', async () => { prisma.enrollment.findMany.mockResolvedValue([ - { cohortId: 7, cohort: makeCohort({ id: 7, name: 'Newest' }) }, - { cohortId: 3, cohort: makeCohort({ id: 3, name: 'Older' }) }, + { + cohortId: 7, + cohort: makeCohort({ id: 7, name: 'Newest' }), + enrolledAt: DATE, + }, + { + cohortId: 3, + cohort: makeCohort({ id: 3, name: 'Older' }), + enrolledAt: DATE, + }, ]); prisma.task.findMany.mockResolvedValue([]); prisma.submission.findMany.mockResolvedValue([]); diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..3edde73 --- /dev/null +++ b/frontend/src/app/admin/settings/page.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@components/ui/card"; +import { Input } from "@components/ui/input"; +import { Label } from "@components/ui/label"; +import { PageContainer, PageHeader } from "@components/shared"; +import { LoadingState } from "@components/common"; +import { useSettings, useUpdateSettings, type PlatformSettings } from "@features/settings"; +import { ApiError } from "@lib/api-client"; + +type SettingsFormProps = { + settings: PlatformSettings; + onSaved: () => void; +}; + +const SettingsForm = ({ settings, onSaved }: SettingsFormProps) => { + const { save, isPending } = useUpdateSettings(); + + const [month1, setMonth1] = useState(settings.stipendMonth1); + const [month2, setMonth2] = useState(settings.stipendMonth2); + const [month3, setMonth3] = useState(settings.stipendMonth3); + + const handleSave = async () => { + const m1 = parseFloat(month1); + const m2 = parseFloat(month2); + const m3 = parseFloat(month3); + + if (!month1 || isNaN(m1) || m1 <= 0) { + toast.error("Month 1 stipend must be a positive number."); + return; + } + if (!month2 || isNaN(m2) || m2 <= 0) { + toast.error("Month 2 stipend must be a positive number."); + return; + } + if (!month3 || isNaN(m3) || m3 <= 0) { + toast.error("Month 3 stipend must be a positive number."); + return; + } + + try { + await save({ stipendMonth1: m1, stipendMonth2: m2, stipendMonth3: m3 }); + toast.success("Stipend settings saved."); + onSaved(); + } catch (err) { + toast.error( + err instanceof ApiError ? err.message : "Failed to save settings.", + ); + } + }; + + return ( + + + Monthly Stipend Amounts (USD) + + Set the default stipend for each month of a student's program. + These appear as presets when recording a payment. + + + +
+
+ + setMonth1(e.target.value)} + /> +
+
+ + setMonth2(e.target.value)} + /> +
+
+ + setMonth3(e.target.value)} + /> +
+
+ + +
+
+ ); +}; + +const Page = () => { + const { data: settings, isLoading, refetch } = useSettings(); + + return ( + + + {isLoading || !settings ? ( + + ) : ( + + )} + + ); +}; + +export default Page; diff --git a/frontend/src/app/admin/users/[id]/page.tsx b/frontend/src/app/admin/users/[id]/page.tsx index 13fe957..61c281e 100644 --- a/frontend/src/app/admin/users/[id]/page.tsx +++ b/frontend/src/app/admin/users/[id]/page.tsx @@ -43,6 +43,7 @@ import { Separator } from "@components/ui/separator"; import { COHORT_STATUS_VARIANT } from "@constants/cohorts"; import { CURRENCIES } from "@constants/payments"; import { useUser, useUserEnrollments, useUserPaymentStats, useRecordPayment, useNotifyWalletMissing } from "@features/users/hooks"; +import { useSettings } from "@features/settings"; import { ApiError } from "@lib/api-client"; import { resolveAssetUrl } from "@lib/config"; import { isProfileComplete } from "@utils/user"; @@ -65,6 +66,7 @@ export default function UserDetailPage({ const { record, isPending: submitting } = useRecordPayment(); const { notify: notifyWallet, isPending: sendingWalletReminder } = useNotifyWalletMissing(); + const { data: platformSettings } = useSettings(); const [dialogOpen, setDialogOpen] = useState(false); const [amount, setAmount] = useState(""); @@ -409,6 +411,27 @@ export default function UserDetailPage({ + {platformSettings && ( +
+ {[ + { label: "Month 1", value: platformSettings.stipendMonth1 }, + { label: "Month 2", value: platformSettings.stipendMonth2 }, + { label: "Month 3", value: platformSettings.stipendMonth3 }, + ].map(({ label, value }) => ( + + ))} +
+ )}
diff --git a/frontend/src/app/api/auth/exchange/route.ts b/frontend/src/app/api/auth/exchange/route.ts new file mode 100644 index 0000000..a8fed50 --- /dev/null +++ b/frontend/src/app/api/auth/exchange/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; + +import { + ACCESS_COOKIE_NAME, + verifyAccessToken, +} from "@lib/auth/access-token"; + +/** + * Cross-domain OAuth bridge. + * + * The backend cannot set httpOnly cookies on the Vercel domain, so after OAuth + * it redirects here with the access token as ?token=. We verify it server-side + * and set a Vercel-domain httpOnly cookie so the middleware can read it. + */ +export async function GET(req: Request): Promise { + const { searchParams, origin } = new URL(req.url); + const token = searchParams.get("token"); + + if (!token) { + return NextResponse.redirect(new URL("/sign-in", origin)); + } + + const payload = await verifyAccessToken(token); + if (!payload) { + return NextResponse.redirect(new URL("/sign-in", origin)); + } + + const { protocol } = new URL(req.url); + const res = NextResponse.redirect(new URL("/auth/callback", origin)); + res.cookies.set(ACCESS_COOKIE_NAME, token, { + httpOnly: true, + secure: protocol === "https:", + sameSite: "lax", + path: "/", + maxAge: 15 * 60, + }); + return res; +} diff --git a/frontend/src/app/student/profile/page.tsx b/frontend/src/app/student/profile/page.tsx index 09a57c3..53b4954 100644 --- a/frontend/src/app/student/profile/page.tsx +++ b/frontend/src/app/student/profile/page.tsx @@ -26,7 +26,7 @@ const Page = () => { - + diff --git a/frontend/src/components/layout/sidebar-layout.tsx b/frontend/src/components/layout/sidebar-layout.tsx index f3760e8..8b3b3ee 100644 --- a/frontend/src/components/layout/sidebar-layout.tsx +++ b/frontend/src/components/layout/sidebar-layout.tsx @@ -11,6 +11,7 @@ import { LayoutDashboard, LogOut, Menu, + Settings, User, Users, } from "lucide-react"; @@ -57,6 +58,7 @@ const NAV_ITEMS_BY_ROLE: Record = { { title: "Tasks", href: "/admin/tasks", icon: CheckSquare }, { title: "Notifications", href: "/admin/notifications", icon: Bell }, { title: "Students", href: "/admin/users", icon: Users }, + { title: "Settings", href: "/admin/settings", icon: Settings }, ], }; diff --git a/frontend/src/features/dashboard/components/payment-progress.tsx b/frontend/src/features/dashboard/components/payment-progress.tsx index 78c0bae..ee5cc14 100644 --- a/frontend/src/features/dashboard/components/payment-progress.tsx +++ b/frontend/src/features/dashboard/components/payment-progress.tsx @@ -6,9 +6,12 @@ import { Card, CardContent, CardHeader, CardTitle } from "@components/ui/card"; import { RadialProgress } from "@components/common"; import type { MonthlyPayment } from "@types"; -export type PaymentProgressProps = { monthlyPayment: MonthlyPayment }; +export type PaymentProgressProps = { + monthlyPayment: MonthlyPayment; + stipendAmount: string | null; +}; -export const PaymentProgress = ({ monthlyPayment }: PaymentProgressProps) => { +export const PaymentProgress = ({ monthlyPayment, stipendAmount }: PaymentProgressProps) => { const { tasksThisMonth, approvedThisMonth, paymentDate, eligible } = monthlyPayment; @@ -52,6 +55,11 @@ export const PaymentProgress = ({ monthlyPayment }: PaymentProgressProps) => { Monthly Stipend + {stipendAmount && ( + + ${stipendAmount} USD + + )} {eligible && ( diff --git a/frontend/src/features/dashboard/components/student-view.tsx b/frontend/src/features/dashboard/components/student-view.tsx index 1100d8c..41da845 100644 --- a/frontend/src/features/dashboard/components/student-view.tsx +++ b/frontend/src/features/dashboard/components/student-view.tsx @@ -5,6 +5,7 @@ import { Clock } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@components/ui/card"; import { Progress } from "@components/ui/progress"; +import { useSettings } from "@features/settings"; import type { StudentDashboard } from "../types"; import { StudentAnalytics } from "./student-analytics"; import { PaymentProgress } from "./payment-progress"; @@ -13,6 +14,18 @@ export type StudentViewProps = { dashboard: StudentDashboard }; export const StudentView = ({ dashboard }: StudentViewProps) => { const { taskStats, nextDeadline, analytics, monthlyPayment } = dashboard; + const { data: settings } = useSettings(); + + const { enrollmentMonth } = monthlyPayment; + const stipendAmount = + settings && + (enrollmentMonth === 1 + ? settings.stipendMonth1 + : enrollmentMonth === 2 + ? settings.stipendMonth2 + : enrollmentMonth === 3 + ? settings.stipendMonth3 + : null); const progressPercent = taskStats.total > 0 ? Math.round((taskStats.approved / taskStats.total) * 100) @@ -81,7 +94,7 @@ export const StudentView = ({ dashboard }: StudentViewProps) => {
- + diff --git a/frontend/src/features/profile/components/profile-form.tsx b/frontend/src/features/profile/components/profile-form.tsx index e29a49f..f1e302a 100644 --- a/frontend/src/features/profile/components/profile-form.tsx +++ b/frontend/src/features/profile/components/profile-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner"; @@ -84,6 +85,20 @@ export const ProfileForm = ({ user, onSaved }: ProfileFormProps) => { }, }); + useEffect(() => { + form.reset({ + name: user.name ?? "", + bio: user.bio ?? "", + linkedin: user.linkedin ?? "", + twitter: user.twitter ?? "", + facebook: user.facebook ?? "", + github: user.github ?? "", + portfolio: user.portfolio ?? "", + telegram: user.telegram ?? "", + whatsapp: user.whatsapp ?? "", + }); + }, [user]); // eslint-disable-line react-hooks/exhaustive-deps + const onSubmit = async (data: ProfileFormValues) => { const payload: ProfileUpdate = { name: data.name.trim(), diff --git a/frontend/src/features/settings/api.ts b/frontend/src/features/settings/api.ts new file mode 100644 index 0000000..7fe3ceb --- /dev/null +++ b/frontend/src/features/settings/api.ts @@ -0,0 +1,16 @@ +import { apiClient } from "@lib/api-client"; +import type { PlatformSettings } from "./types"; + +export interface UpdateSettingsPayload { + stipendMonth1?: number; + stipendMonth2?: number; + stipendMonth3?: number; +} + +export const getSettings = (): Promise => + apiClient.get("/settings"); + +export const updateSettings = ( + data: UpdateSettingsPayload, +): Promise => + apiClient.patch("/settings", data); diff --git a/frontend/src/features/settings/hooks.ts b/frontend/src/features/settings/hooks.ts new file mode 100644 index 0000000..d5e7688 --- /dev/null +++ b/frontend/src/features/settings/hooks.ts @@ -0,0 +1,22 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useAsyncResource } from "@hooks/use-async-resource"; +import { getSettings, updateSettings, type UpdateSettingsPayload } from "./api"; + +export const useSettings = () => useAsyncResource(getSettings, []); + +export const useUpdateSettings = () => { + const [isPending, setIsPending] = useState(false); + + const save = useCallback(async (data: UpdateSettingsPayload) => { + setIsPending(true); + try { + return await updateSettings(data); + } finally { + setIsPending(false); + } + }, []); + + return { save, isPending }; +}; diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts new file mode 100644 index 0000000..55b01e8 --- /dev/null +++ b/frontend/src/features/settings/index.ts @@ -0,0 +1,4 @@ +export type { PlatformSettings } from "./types"; +export type { UpdateSettingsPayload } from "./api"; +export { getSettings, updateSettings } from "./api"; +export { useSettings, useUpdateSettings } from "./hooks"; diff --git a/frontend/src/features/settings/types.ts b/frontend/src/features/settings/types.ts new file mode 100644 index 0000000..5fb8840 --- /dev/null +++ b/frontend/src/features/settings/types.ts @@ -0,0 +1,5 @@ +export interface PlatformSettings { + stipendMonth1: string; + stipendMonth2: string; + stipendMonth3: string; +} diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 48a4a43..26b43c8 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -19,6 +19,7 @@ export interface MonthlyPayment { approvedThisMonth: number; paymentDate: string; eligible: boolean; + enrollmentMonth: number; } export interface StudentDashboard {