diff --git a/src/app/config/routePaths.ts b/src/app/config/routePaths.ts
index d54fceb..dcacf22 100644
--- a/src/app/config/routePaths.ts
+++ b/src/app/config/routePaths.ts
@@ -24,6 +24,7 @@ export const routePaths = {
adminInternships: '/admin/internships',
adminCandidateFiltering: '/admin/candidate-filtering',
adminShortlists: '/admin/shortlists',
+ adminEligibleStudents: '/admin/eligible-students',
unauthorized: '/unauthorized',
} as const
diff --git a/src/app/layouts/AdminLayout.test.tsx b/src/app/layouts/AdminLayout.test.tsx
index 97b9907..a755882 100644
--- a/src/app/layouts/AdminLayout.test.tsx
+++ b/src/app/layouts/AdminLayout.test.tsx
@@ -121,6 +121,9 @@ describe('AdminLayout', () => {
expect(screen.getAllByRole('button', { name: /switch to dark mode/i })).toHaveLength(1)
await user.click(screen.getByRole('button', { name: 'Log Out' }))
+ const logoutDialog = await screen.findByRole('dialog', { name: 'Log Out' })
+ expect(logout).not.toHaveBeenCalled()
+ await user.click(within(logoutDialog).getByRole('button', { name: 'Log Out' }))
expect(logout).toHaveBeenCalledOnce()
})
diff --git a/src/app/layouts/AdminLayout.tsx b/src/app/layouts/AdminLayout.tsx
index 27ba313..e24083c 100644
--- a/src/app/layouts/AdminLayout.tsx
+++ b/src/app/layouts/AdminLayout.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocation, useOutlet } from 'react-router-dom'
+import { LogoutConfirmDialog } from '../../shared/components/overlays/LogoutConfirmDialog'
import { ThemeToggle } from '../../shared/components/ui/ThemeToggle'
import { useAuth } from '../../shared/hooks/useAuth'
import { AdminSidebar } from './admin/AdminSidebar'
@@ -20,6 +21,7 @@ export function AdminLayout() {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
const [isMobileViewport, setIsMobileViewport] = useState(isMobileViewportNow)
+ const [isLogoutConfirmOpen, setIsLogoutConfirmOpen] = useState(false)
const menuButtonRef = useRef(null)
const sidebarRef = useRef(null)
const firstNavigationItemRef = useRef(null)
@@ -105,7 +107,7 @@ export function AdminLayout() {
isMobileViewport={isMobileViewport}
navigationItems={adminNavigation}
onCloseMobile={closeMobileDrawer}
- onLogout={() => void auth.logout()}
+ onLogout={() => setIsLogoutConfirmOpen(true)}
onToggleCollapsed={() => setIsSidebarCollapsed((current) => !current)}
sidebarRef={sidebarRef}
/>
@@ -153,6 +155,16 @@ export function AdminLayout() {
+
+ {isLogoutConfirmOpen ? (
+ setIsLogoutConfirmOpen(false)}
+ onConfirm={async () => {
+ await auth.logout()
+ setIsLogoutConfirmOpen(false)
+ }}
+ />
+ ) : null}
)
}
diff --git a/src/app/layouts/StudentLayout.test.tsx b/src/app/layouts/StudentLayout.test.tsx
index fe5ba5d..ecfd776 100644
--- a/src/app/layouts/StudentLayout.test.tsx
+++ b/src/app/layouts/StudentLayout.test.tsx
@@ -132,6 +132,9 @@ describe('StudentLayout', () => {
expect(drawerCloseButton).toHaveFocus()
await user.click(screen.getByRole('button', { name: 'Log Out' }))
+ const logoutDialog = await screen.findByRole('dialog', { name: 'Log Out' })
+ expect(logout).not.toHaveBeenCalled()
+ await user.click(within(logoutDialog).getByRole('button', { name: 'Log Out' }))
expect(logout).toHaveBeenCalledOnce()
await user.keyboard('{Escape}')
diff --git a/src/app/layouts/StudentLayout.tsx b/src/app/layouts/StudentLayout.tsx
index 234881c..57f21c1 100644
--- a/src/app/layouts/StudentLayout.tsx
+++ b/src/app/layouts/StudentLayout.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocation, useOutlet } from 'react-router-dom'
+import { LogoutConfirmDialog } from '../../shared/components/overlays/LogoutConfirmDialog'
import { ThemeToggle } from '../../shared/components/ui/ThemeToggle'
import { useAuth } from '../../shared/hooks/useAuth'
import { StudentSidebar } from './student/StudentSidebar'
@@ -23,6 +24,7 @@ export function StudentLayout() {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
const [isMobileViewport, setIsMobileViewport] = useState(isMobileViewportNow)
+ const [isLogoutConfirmOpen, setIsLogoutConfirmOpen] = useState(false)
const menuButtonRef = useRef(null)
const sidebarRef = useRef(null)
const firstNavigationItemRef = useRef(null)
@@ -136,7 +138,7 @@ export function StudentLayout() {
isMobileViewport={isMobileViewport}
navigationItems={studentNavigation}
onCloseMobile={closeMobileDrawer}
- onLogout={() => void auth.logout()}
+ onLogout={() => setIsLogoutConfirmOpen(true)}
onToggleCollapsed={() => setIsSidebarCollapsed((current) => !current)}
sidebarRef={sidebarRef}
studentName={studentName}
@@ -185,6 +187,16 @@ export function StudentLayout() {
+
+ {isLogoutConfirmOpen ? (
+ setIsLogoutConfirmOpen(false)}
+ onConfirm={async () => {
+ await auth.logout()
+ setIsLogoutConfirmOpen(false)
+ }}
+ />
+ ) : null}
)
}
diff --git a/src/app/layouts/admin/adminNavigation.ts b/src/app/layouts/admin/adminNavigation.ts
index d122df7..b1b6659 100644
--- a/src/app/layouts/admin/adminNavigation.ts
+++ b/src/app/layouts/admin/adminNavigation.ts
@@ -17,4 +17,5 @@ export const adminNavigation: readonly AdminNavigationItem[] = [
icon: 'filter_alt',
},
{ label: 'Shortlists', route: routePaths.adminShortlists, icon: 'assignment_turned_in' },
+ { label: 'Eligible Students', route: routePaths.adminEligibleStudents, icon: 'how_to_reg' },
]
diff --git a/src/app/router/lazyRoutes.ts b/src/app/router/lazyRoutes.ts
index c90c13f..155db2f 100644
--- a/src/app/router/lazyRoutes.ts
+++ b/src/app/router/lazyRoutes.ts
@@ -122,3 +122,9 @@ export const ShortlistsPage = lazy(() =>
default: module.ShortlistsPage,
})),
)
+
+export const EligibleStudentsPage = lazy(() =>
+ import('../../features/eligible-students/pages/EligibleStudentsPage').then((module) => ({
+ default: module.EligibleStudentsPage,
+ })),
+)
diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx
index ae2f0ea..1dee19b 100644
--- a/src/app/router/routes.tsx
+++ b/src/app/router/routes.tsx
@@ -25,6 +25,7 @@ import {
InternshipManagementPage,
CandidateFilteringPage,
ShortlistsPage,
+ EligibleStudentsPage,
AdminForgotPasswordPage,
AdminLoginPage,
AdminVerifyResetOtpPage,
@@ -270,6 +271,10 @@ export const routes: RouteObject[] = [
path: routePaths.adminShortlists,
element: withSuspense( , ),
},
+ {
+ path: routePaths.adminEligibleStudents,
+ element: withSuspense( ),
+ },
],
},
...fallbackRoutes,
diff --git a/src/features/academic-ledger/api/academicLedgerApi.ts b/src/features/academic-ledger/api/academicLedgerApi.ts
index dabde3c..b355636 100644
--- a/src/features/academic-ledger/api/academicLedgerApi.ts
+++ b/src/features/academic-ledger/api/academicLedgerApi.ts
@@ -85,4 +85,10 @@ export const academicLedgerApi = {
)
return ledgerCommitResponseSchema.parse(response)
},
+
+ async remove(uploadId: string) {
+ await httpClient(`/admin/academic-ledger/uploads/${encodeURIComponent(uploadId)}`, {
+ method: 'DELETE',
+ })
+ },
}
diff --git a/src/features/academic-ledger/components/LedgerUploadPanel.tsx b/src/features/academic-ledger/components/LedgerUploadPanel.tsx
index c43aa3e..17d284f 100644
--- a/src/features/academic-ledger/components/LedgerUploadPanel.tsx
+++ b/src/features/academic-ledger/components/LedgerUploadPanel.tsx
@@ -43,7 +43,7 @@ export function LedgerUploadPanel({
const parsed = academicLedgerFileSchema.safeParse(nextFile)
if (!parsed.success) {
setFile(null)
- setValidationMessage(parsed.error.issues[0]?.message ?? 'Choose a valid CSV file.')
+ setValidationMessage(parsed.error.issues[0]?.message ?? 'Choose a valid CSV or Excel file.')
if (inputRef.current) inputRef.current.value = ''
return
}
@@ -68,20 +68,17 @@ export function LedgerUploadPanel({
return (
-
Upload academic records here.
-
- Select one official UTF-8 CSV ledger file. The file is parsed, staged, and validated
- before any academic record can be committed.
-
+
Upload academic records
+
Upload a CSV or Excel file. It's staged and validated before you commit it.
cloud_upload
- Drag & drop official transcript file here or click to browse
- Supported format: official academic CSV ledger file · Maximum size: 5 MiB
+ Drag & drop a file here, or click to browse
+ CSV or Excel (.xlsx) · Max 5 MiB
- Required headers and row values are validated by the backend before commit.
+ Columns and values are checked automatically before you can commit.
{file ? (
@@ -139,11 +136,11 @@ export function LedgerUploadPanel({
file && onUpload(file)}>
- Process and Stage Ledger
+ Upload
{file ? (
- Clear selected file
+ Clear
) : null}
diff --git a/src/features/academic-ledger/components/LedgerUploadsTable.tsx b/src/features/academic-ledger/components/LedgerUploadsTable.tsx
index e43c1d4..67b69f6 100644
--- a/src/features/academic-ledger/components/LedgerUploadsTable.tsx
+++ b/src/features/academic-ledger/components/LedgerUploadsTable.tsx
@@ -3,13 +3,17 @@ import { Button } from '../../../shared/components/ui/Button'
import { StatusBadge } from '../../../shared/components/ui/StatusBadge'
import { mapUploadStatus, mapValidationStatus } from '../mappers/academicLedgerMappers'
+const DELETE_BLOCKED_STATUSES = new Set(['PROCESSING', 'COMMITTING', 'COMMITTED'])
+
export function LedgerUploadsTable({
items,
+ onDelete,
onSelect,
selectedId,
}: {
items: ApiAcademicLedgerUploadSummaryResponse[]
selectedId: string | null
+ onDelete: (item: ApiAcademicLedgerUploadSummaryResponse) => void
onSelect: (uploadId: string) => void
}) {
return (
@@ -51,7 +55,7 @@ export function LedgerUploadsTable({
{validation.label}
{item.totalRows}
-
+
onSelect(item.uploadId)}
@@ -59,6 +63,11 @@ export function LedgerUploadsTable({
>
Inspect
+ {!DELETE_BLOCKED_STATUSES.has(item.uploadStatus) ? (
+ onDelete(item)} variant="secondary">
+ Remove
+
+ ) : null}
)
diff --git a/src/features/academic-ledger/hooks/useLedgerUpload.ts b/src/features/academic-ledger/hooks/useLedgerUpload.ts
index 2d2ea7e..d35f428 100644
--- a/src/features/academic-ledger/hooks/useLedgerUpload.ts
+++ b/src/features/academic-ledger/hooks/useLedgerUpload.ts
@@ -45,3 +45,13 @@ export function useUploadLedger() {
},
})
}
+
+export function useDeleteLedgerUpload() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: (uploadId: string) => academicLedgerApi.remove(uploadId),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: academicLedgerKeys.uploads() })
+ },
+ })
+}
diff --git a/src/features/academic-ledger/pages/AcademicLedgerPage.tsx b/src/features/academic-ledger/pages/AcademicLedgerPage.tsx
index ac2d79b..40abfa0 100644
--- a/src/features/academic-ledger/pages/AcademicLedgerPage.tsx
+++ b/src/features/academic-ledger/pages/AcademicLedgerPage.tsx
@@ -1,41 +1,38 @@
-import { useEffect } from 'react'
+import { useEffect, useState } from 'react'
import { mapApiError } from '../../../shared/api/apiErrorMapper'
import { SearchInput } from '../../../shared/components/data/SearchInput'
import { PaginationBar } from '../../../shared/components/data/PaginationBar'
import { EmptyState } from '../../../shared/components/feedback/EmptyState'
import { ErrorState } from '../../../shared/components/feedback/ErrorState'
import { PageHeader } from '../../../shared/components/layout/PageHeader'
+import { ConfirmDialog } from '../../../shared/components/overlays/ConfirmDialog'
+import { Button } from '../../../shared/components/ui/Button'
import { LedgerSelectedBatchSkeleton, LedgerUploadsTableSkeleton } from '../../../shared/skeletons'
-import { LedgerAcademicInspection } from '../components/LedgerAcademicInspection'
import { LedgerCommitControl } from '../components/LedgerCommitControl'
import { LedgerReviewSection } from '../components/LedgerReviewSection'
import { LedgerUploadPanel } from '../components/LedgerUploadPanel'
import { LedgerUploadStatus } from '../components/LedgerUploadStatus'
import { LedgerUploadsTable } from '../components/LedgerUploadsTable'
import { useAcademicLedgerUrlState } from '../hooks/useAcademicLedgerUrlState'
-import { useLedgerUploadDetail, useLedgerUploads, useUploadLedger } from '../hooks/useLedgerUpload'
+import {
+ useDeleteLedgerUpload,
+ useLedgerUploadDetail,
+ useLedgerUploads,
+ useUploadLedger,
+} from '../hooks/useLedgerUpload'
+import type { ApiAcademicLedgerUploadSummaryResponse } from '../../../shared/api/generated/cvManagementApi.types'
const pageTitle = 'Academic Ledger Management | CV Management & Filtering System'
-const pageDescription =
- 'Centralized academic data validation repository. Import official undergraduate transcripts ' +
- 'via batch files to evaluate data parameters, review staged records, and protect academic data ' +
- 'from unauthorized modification.'
+const pageDescription = 'Upload official transcripts, review them, and commit academic records.'
export function AcademicLedgerPage() {
- const {
- state,
- rowSearchInput,
- studentSearchInput,
- selectUpload,
- setRowSearchInput,
- setStudentSearchInput,
- updateRows,
- updateStudents,
- updateUploads,
- } = useAcademicLedgerUrlState()
+ const { state, rowSearchInput, selectUpload, setRowSearchInput, updateRows, updateUploads } =
+ useAcademicLedgerUrlState()
const uploads = useLedgerUploads(state.uploads)
const selected = useLedgerUploadDetail(state.uploadId)
const upload = useUploadLedger()
+ const deleteUpload = useDeleteLedgerUpload()
+ const [deleting, setDeleting] = useState(null)
useEffect(() => {
const previousTitle = document.title
@@ -69,13 +66,6 @@ export function AcademicLedgerPage() {
}
/>
-
-
{state.uploadId && selected.isPending ? : null}
{selected.data ? : null}
{selected.isError ? (
@@ -147,6 +137,7 @@ export function AcademicLedgerPage() {
{uploads.data?.items.length ? (
@@ -170,6 +161,45 @@ export function AcademicLedgerPage() {
/>
) : null}
+
+ {deleting ? (
+ setDeleting(null)}
+ title="Remove upload"
+ >
+
+ Remove {deleting.originalFilename} ? This cannot be undone.
+
+ {deleteUpload.isError ? (
+
+ {mapApiError(deleteUpload.error, 'protected').message}
+
+ ) : null}
+
+ setDeleting(null)}
+ variant="secondary"
+ >
+ Cancel
+
+ {
+ deleteUpload.mutate(deleting.uploadId, {
+ onSuccess: () => {
+ if (state.uploadId === deleting.uploadId) selectUpload(null)
+ setDeleting(null)
+ },
+ })
+ }}
+ >
+ Remove
+
+
+
+ ) : null}
)
}
diff --git a/src/features/academic-ledger/schemas/ledgerSchemas.ts b/src/features/academic-ledger/schemas/ledgerSchemas.ts
index 7a69a7d..5a37a4c 100644
--- a/src/features/academic-ledger/schemas/ledgerSchemas.ts
+++ b/src/features/academic-ledger/schemas/ledgerSchemas.ts
@@ -47,11 +47,16 @@ export const ledgerValidationErrorSchema: z.ZodType =
- ledgerUploadSummaryObject
+// The bundled OpenAPI contract still declares `contentType` as the `text/csv` literal only; the
+// backend and this schema now also accept Excel (.xlsx) uploads, so the generated API type is
+// widened locally here rather than narrowing the runtime schema to match a stale contract.
+type WithLedgerContentType = Omit & { contentType: z.infer }
+
+export const ledgerUploadSummarySchema: z.ZodType<
+ WithLedgerContentType
+> = ledgerUploadSummaryObject
-export const ledgerUploadDetailSchema: z.ZodType =
- ledgerUploadSummaryObject
- .extend({
- statusMessage: z.string().min(1).max(500),
- nextPollAfterSeconds: z.number().int().min(1).max(30).nullable(),
- })
- .strict()
+export const ledgerUploadDetailSchema: z.ZodType<
+ WithLedgerContentType
+> = ledgerUploadSummaryObject
+ .extend({
+ statusMessage: z.string().min(1).max(500),
+ nextPollAfterSeconds: z.number().int().min(1).max(30).nullable(),
+ })
+ .strict()
-export const pagedLedgerUploadsSchema: z.ZodType =
- createPagedResponseSchema(ledgerUploadSummarySchema)
+export const pagedLedgerUploadsSchema: z.ZodType<
+ Omit & {
+ items: WithLedgerContentType[]
+ }
+> = createPagedResponseSchema(ledgerUploadSummarySchema)
export const ledgerStagedRowSchema: z.ZodType = z
.object({
@@ -140,8 +155,12 @@ export const ledgerCommitResponseSchema: z.ZodType {
it('rejects non-CSV files before upload and accepts a valid CSV', async () => {
const user = userEvent.setup({ applyAccept: false })
renderPage()
- const input = await screen.findByLabelText('Official academic ledger CSV')
+ const input = await screen.findByLabelText('Official academic ledger file')
await user.upload(input, new File(['not csv'], 'results.txt', { type: 'text/plain' }))
- expect(screen.getByRole('alert')).toHaveTextContent('Choose a .csv file')
- expect(screen.getByRole('button', { name: 'Process and Stage Ledger' })).toBeDisabled()
+ expect(screen.getByRole('alert')).toHaveTextContent('Choose a .csv or .xlsx file')
+ expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled()
await user.upload(
input,
new File(['student,course\n1,CS4010'], 'results.csv', { type: 'text/csv' }),
)
- await user.click(screen.getByRole('button', { name: 'Process and Stage Ledger' }))
+ await user.click(screen.getByRole('button', { name: 'Upload' }))
await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('uploadId='))
expect(
await screen.findByText('The file was accepted and processing has started.'),
@@ -98,26 +97,13 @@ describe('AcademicLedgerPage upload workflow', () => {
uploadId: String(params.uploadId),
})
}),
- http.get('/api/v1/admin/students', async () => {
- await delay(120)
- return HttpResponse.json({
- items: registeredStudentsFixture,
- page: {
- page: 0,
- size: 5,
- totalElements: registeredStudentsFixture.length,
- totalPages: 2,
- sort: 'fullName,asc',
- },
- })
- }),
)
const view = renderPage(`${routePaths.adminAcademicLedger}?uploadId=${uploadId}`)
expect(
view.getAllByRole('heading', { level: 1, name: 'Academic Ledger Management' }),
).toHaveLength(1)
- expect(view.getAllByLabelText('Official academic ledger CSV')).toHaveLength(1)
+ expect(view.getAllByLabelText('Official academic ledger file')).toHaveLength(1)
expect(
view.getByRole('status', { name: 'Loading selected ledger batch' }),
).toBeInTheDocument()
@@ -131,7 +117,7 @@ describe('AcademicLedgerPage upload workflow', () => {
expect(
view.getAllByRole('heading', { level: 1, name: 'Academic Ledger Management' }),
).toHaveLength(1)
- expect(view.getAllByLabelText('Official academic ledger CSV')).toHaveLength(1)
+ expect(view.getAllByLabelText('Official academic ledger file')).toHaveLength(1)
},
)
})
diff --git a/src/features/admin-auth/components/AdminCreatePasswordForm.tsx b/src/features/admin-auth/components/AdminCreatePasswordForm.tsx
index 75a38b5..35ea987 100644
--- a/src/features/admin-auth/components/AdminCreatePasswordForm.tsx
+++ b/src/features/admin-auth/components/AdminCreatePasswordForm.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { FormField } from '../../../shared/components/forms/FormField'
-import { TextInput } from '../../../shared/components/forms/TextInput'
+import { PasswordInput } from '../../../shared/components/forms/PasswordInput'
import { Button } from '../../../shared/components/ui/Button'
import {
adminCreatePasswordSchema,
@@ -40,13 +40,12 @@ export function AdminCreatePasswordForm({ isSubmitting, onSubmit }: AdminCreateP
Use at least 8 characters with uppercase, lowercase, number, and special character.
-
setValues((current) => ({ ...current, newPassword: event.target.value }))
}
- type="password"
value={values.newPassword}
/>
@@ -55,13 +54,12 @@ export function AdminCreatePasswordForm({ isSubmitting, onSubmit }: AdminCreateP
htmlFor="admin-confirm-password"
label="Confirm New Password"
>
-
setValues((current) => ({ ...current, confirmPassword: event.target.value }))
}
- type="password"
value={values.confirmPassword}
/>
diff --git a/src/features/admin-auth/components/AdminLoginForm.tsx b/src/features/admin-auth/components/AdminLoginForm.tsx
index 79e5a5e..7758d7c 100644
--- a/src/features/admin-auth/components/AdminLoginForm.tsx
+++ b/src/features/admin-auth/components/AdminLoginForm.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Link } from 'react-router-dom'
import { routePaths } from '../../../app/config/routePaths'
import { FormField } from '../../../shared/components/forms/FormField'
+import { PasswordInput } from '../../../shared/components/forms/PasswordInput'
import { TextInput } from '../../../shared/components/forms/TextInput'
import { Button } from '../../../shared/components/ui/Button'
import {
@@ -46,14 +47,13 @@ export function AdminLoginForm({ isSubmitting, onSubmit }: AdminLoginFormProps)
/>
-
setValues((current) => ({ ...current, password: event.target.value }))
}
placeholder="Enter your security password"
- type="password"
value={values.password}
/>
diff --git a/src/features/admin-auth/pages/AdminLoginPage.tsx b/src/features/admin-auth/pages/AdminLoginPage.tsx
index 8ac0227..1a7db75 100644
--- a/src/features/admin-auth/pages/AdminLoginPage.tsx
+++ b/src/features/admin-auth/pages/AdminLoginPage.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { useNavigate } from 'react-router-dom'
+import { Link, useNavigate } from 'react-router-dom'
import { routePaths } from '../../../app/config/routePaths'
import { mapApiError } from '../../../shared/api/apiErrorMapper'
import { authStorage } from '../../../shared/auth/authStorage'
@@ -44,6 +44,12 @@ export function AdminLoginPage() {
}
>
+
+
+ arrow_back
+
+ Back
+
Admin Login
{message ? (
diff --git a/src/features/cv-builder/components/CvPreviewPanel.tsx b/src/features/cv-builder/components/CvPreviewPanel.tsx
index 02aa513..9b336d5 100644
--- a/src/features/cv-builder/components/CvPreviewPanel.tsx
+++ b/src/features/cv-builder/components/CvPreviewPanel.tsx
@@ -79,7 +79,30 @@ export function CvPreviewPanel({
)
}
+const previewStyles = `
+html{background:#eef0f3;color:#1c1c1c;font:14px/1.55 'Liberation Sans',Arial,Helvetica,sans-serif}
+body{margin:0;padding:40px 20px}
+.cv-document{max-width:760px;margin:0 auto;background:#fff;padding:48px 56px;border:1px solid #e2e2e2;box-shadow:0 1px 4px rgba(0,0,0,.08)}
+.cv-header{text-align:center;margin-bottom:18px;padding-bottom:14px;border-bottom:2px solid #1c385e}
+.cv-header h1{font-size:30px;margin:0 0 6px;letter-spacing:.4px}
+.cv-headline{font-style:italic;color:#444;margin:0 0 8px;font-size:14px}
+.cv-contact,.cv-links{margin:2px 0;font-size:12.5px;color:#333}
+.cv-links a{color:#1c385e;text-decoration:underline}
+section{margin-top:20px}
+section:first-of-type{margin-top:0}
+section>h2{font-size:13px;letter-spacing:1px;text-transform:uppercase;font-weight:700;color:#1c385e;margin:0 0 8px;padding-bottom:4px;border-bottom:1px solid #1c385e}
+article{margin-bottom:14px}
+article:last-child{margin-bottom:0}
+article>h3{font-size:14.5px;margin:0;font-weight:700}
+.cv-meta{margin:2px 0 6px;font-size:12.5px;font-style:italic;color:#555}
+p{margin:4px 0;font-size:13.5px}
+ul{margin:6px 0;padding-left:20px}
+li{margin:3px 0;font-size:13.5px}
+a{color:#1c385e;text-decoration:none;border-bottom:1px solid #ccc}
+strong{font-weight:700}
+`.replace(/\n/g, '')
+
export function buildPreviewDocument(htmlPreview: string) {
const sanitizedPreview = sanitizeCvHtml(htmlPreview)
- return `
${sanitizedPreview}`
+ return `
${sanitizedPreview}`
}
diff --git a/src/features/cv-builder/pages/CvBuilderPage.tsx b/src/features/cv-builder/pages/CvBuilderPage.tsx
index 4a40dec..5c521d2 100644
--- a/src/features/cv-builder/pages/CvBuilderPage.tsx
+++ b/src/features/cv-builder/pages/CvBuilderPage.tsx
@@ -272,15 +272,6 @@ export function CvBuilderPage() {
/>
-
-
+
+
)
diff --git a/src/features/eligible-students/api/eligibleStudentsApi.ts b/src/features/eligible-students/api/eligibleStudentsApi.ts
new file mode 100644
index 0000000..00c16d7
--- /dev/null
+++ b/src/features/eligible-students/api/eligibleStudentsApi.ts
@@ -0,0 +1,56 @@
+import { httpClient } from '../../../shared/api/httpClient'
+import {
+ eligibleStudentImportResultSchema,
+ eligibleStudentPagedResponseSchema,
+ eligibleStudentSchema,
+} from '../schemas/eligibleStudentSchemas'
+import type {
+ EligibleStudent,
+ EligibleStudentImportResult,
+ EligibleStudentQuery,
+ EligibleStudentRequest,
+ PagedResponse,
+} from '../types/eligibleStudentTypes'
+
+const basePath = '/admin/eligible-students'
+
+function listPath(query: EligibleStudentQuery) {
+ const parameters = new URLSearchParams({
+ page: String(query.page),
+ size: String(query.size),
+ sort: query.sort,
+ })
+ if (query.search.trim()) parameters.set('search', query.search.trim())
+ return `${basePath}?${parameters.toString()}`
+}
+
+export const eligibleStudentsApi = {
+ async list(query: EligibleStudentQuery, signal?: AbortSignal): Promise
> {
+ return eligibleStudentPagedResponseSchema.parse(
+ await httpClient(listPath(query), { signal }),
+ ) as PagedResponse
+ },
+ async create(values: EligibleStudentRequest): Promise {
+ return eligibleStudentSchema.parse(
+ await httpClient(basePath, { method: 'POST', body: values }),
+ ) as EligibleStudent
+ },
+ async update(id: string, values: EligibleStudentRequest): Promise {
+ return eligibleStudentSchema.parse(
+ await httpClient(`${basePath}/${encodeURIComponent(id)}`, {
+ method: 'PATCH',
+ body: values,
+ }),
+ ) as EligibleStudent
+ },
+ async remove(id: string): Promise {
+ await httpClient(`${basePath}/${encodeURIComponent(id)}`, { method: 'DELETE' })
+ },
+ async importFile(file: File): Promise {
+ const body = new FormData()
+ body.set('file', file)
+ return eligibleStudentImportResultSchema.parse(
+ await httpClient(`${basePath}/import`, { method: 'POST', body }),
+ ) as EligibleStudentImportResult
+ },
+}
diff --git a/src/features/eligible-students/components/EligibleStudentForm.tsx b/src/features/eligible-students/components/EligibleStudentForm.tsx
new file mode 100644
index 0000000..e3c3ac2
--- /dev/null
+++ b/src/features/eligible-students/components/EligibleStudentForm.tsx
@@ -0,0 +1,202 @@
+import { useRef, useState } from 'react'
+import { mapApiError } from '../../../shared/api/apiErrorMapper'
+import { FormField } from '../../../shared/components/forms/FormField'
+import { TextInput } from '../../../shared/components/forms/TextInput'
+import { Modal } from '../../../shared/components/overlays/Modal'
+import { Button } from '../../../shared/components/ui/Button'
+import { eligibleStudentFormSchema } from '../schemas/eligibleStudentSchemas'
+import type {
+ EligibleStudent,
+ EligibleStudentFormValues,
+ EligibleStudentRequest,
+} from '../types/eligibleStudentTypes'
+
+type Field = keyof EligibleStudentFormValues
+type FieldErrors = Partial>
+
+const emptyForm: EligibleStudentFormValues = {
+ indexNumber: '',
+ universityEmail: '',
+ fullName: '',
+ academicLevel: '',
+}
+
+function toFormValues(student: EligibleStudent): EligibleStudentFormValues {
+ return {
+ indexNumber: student.indexNumber,
+ universityEmail: student.universityEmail,
+ fullName: student.fullName,
+ academicLevel: String(student.academicLevel) as '3' | '4',
+ }
+}
+
+export function EligibleStudentForm({
+ item,
+ onCancel,
+ onSubmit,
+}: {
+ item?: EligibleStudent
+ onCancel: () => void
+ onSubmit: (values: EligibleStudentRequest) => Promise
+}) {
+ const [values, setValues] = useState(
+ item ? toFormValues(item) : emptyForm,
+ )
+ const [errors, setErrors] = useState({})
+ const [formError, setFormError] = useState()
+ const [isPending, setIsPending] = useState(false)
+ const indexRef = useRef(null)
+
+ const update = (field: F, value: EligibleStudentFormValues[F]) => {
+ setValues((current) => ({ ...current, [field]: value }))
+ setErrors((current) => ({ ...current, [field]: undefined }))
+ setFormError(undefined)
+ }
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ setErrors({})
+ setFormError(undefined)
+ const parsed = eligibleStudentFormSchema.safeParse(values)
+ if (!parsed.success) {
+ const nextErrors: FieldErrors = {}
+ for (const issue of parsed.error.issues) {
+ const field = issue.path[0]
+ if (typeof field === 'string' && !nextErrors[field as Field]) {
+ nextErrors[field as Field] = issue.message
+ }
+ }
+ setErrors(nextErrors)
+ if (nextErrors.indexNumber) window.requestAnimationFrame(() => indexRef.current?.focus())
+ return
+ }
+
+ setIsPending(true)
+ try {
+ await onSubmit({
+ indexNumber: parsed.data.indexNumber.toUpperCase(),
+ universityEmail: parsed.data.universityEmail.toLowerCase(),
+ fullName: parsed.data.fullName,
+ academicLevel: Number(parsed.data.academicLevel) as 3 | 4,
+ })
+ } catch (reason) {
+ const mapped = mapApiError(reason, 'protected')
+ const nextErrors: FieldErrors = {}
+ for (const fieldError of mapped.fieldErrors) {
+ if (fieldError.field in values) {
+ nextErrors[fieldError.field as Field] = fieldError.message
+ }
+ }
+ setErrors(nextErrors)
+ setFormError(mapped.message)
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ const describedBy = (field: Field) => (errors[field] ? `eligible-student-${field}-error` : undefined)
+
+ return (
+
+
+
+ )
+}
diff --git a/src/features/eligible-students/components/EligibleStudentImportPanel.tsx b/src/features/eligible-students/components/EligibleStudentImportPanel.tsx
new file mode 100644
index 0000000..e455e47
--- /dev/null
+++ b/src/features/eligible-students/components/EligibleStudentImportPanel.tsx
@@ -0,0 +1,77 @@
+import { useRef, useState } from 'react'
+import { mapApiError } from '../../../shared/api/apiErrorMapper'
+import { FileUploadField } from '../../../shared/components/forms/FileUploadField'
+import { Button } from '../../../shared/components/ui/Button'
+import { useEligibleStudentMutations } from '../hooks/useEligibleStudents'
+import type { EligibleStudentImportResult } from '../types/eligibleStudentTypes'
+
+export function EligibleStudentImportPanel() {
+ const mutations = useEligibleStudentMutations()
+ const [file, setFile] = useState(null)
+ const [result, setResult] = useState(null)
+ const [error, setError] = useState()
+ const inputRef = useRef(null)
+
+ const submit = async () => {
+ if (!file) return
+ setError(undefined)
+ setResult(null)
+ try {
+ const response = await mutations.importFile.mutateAsync(file)
+ setResult(response)
+ setFile(null)
+ if (inputRef.current) inputRef.current.value = ''
+ } catch (reason) {
+ setError(mapApiError(reason, 'protected').message)
+ }
+ }
+
+ return (
+
+
Bulk Import
+
+ Columns: Index Number , University Email ,{' '}
+ Full Name , Academic Level (3 or 4). Duplicates are
+ skipped.
+
+
+ setFile(event.target.files?.[0] ?? null)}
+ ref={inputRef}
+ />
+ void submit()}
+ >
+ Import
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {result ? (
+
+
+ Imported {result.importedCount} of {result.totalRows} row
+ {result.totalRows === 1 ? '' : 's'}
+ {result.skippedCount > 0 ? ` — ${result.skippedCount} skipped.` : '.'}
+
+ {result.errors.length > 0 ? (
+
+ {result.errors.map((rowError) => (
+
+ Row {rowError.row}: {rowError.message}
+
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/eligible-students/components/EligibleStudentsTable.tsx b/src/features/eligible-students/components/EligibleStudentsTable.tsx
new file mode 100644
index 0000000..a10d7f2
--- /dev/null
+++ b/src/features/eligible-students/components/EligibleStudentsTable.tsx
@@ -0,0 +1,61 @@
+import { StatusBadge } from '../../../shared/components/ui/StatusBadge'
+import { Button } from '../../../shared/components/ui/Button'
+import type { EligibleStudent } from '../types/eligibleStudentTypes'
+
+export function EligibleStudentsTable({
+ disabled,
+ items,
+ onDelete,
+ onEdit,
+}: {
+ disabled: boolean
+ items: EligibleStudent[]
+ onDelete: (student: EligibleStudent) => void
+ onEdit: (student: EligibleStudent) => void
+}) {
+ return (
+
+
+ Eligible student roster
+
+
+ Index Number
+ Full Name
+ University Email
+ Level
+ Status
+ Actions
+
+
+
+ {items.map((student) => (
+
+ {student.indexNumber}
+ {student.fullName}
+ {student.universityEmail}
+ {student.academicLevel}
+
+
+ {student.registered ? 'Registered' : 'Not registered'}
+
+
+
+ onEdit(student)} variant="secondary">
+ Edit
+
+ onDelete(student)}
+ title={student.registered ? 'Registered students cannot be removed.' : undefined}
+ variant="secondary"
+ >
+ Delete
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/features/eligible-students/hooks/useEligibleStudents.ts b/src/features/eligible-students/hooks/useEligibleStudents.ts
new file mode 100644
index 0000000..978c3a8
--- /dev/null
+++ b/src/features/eligible-students/hooks/useEligibleStudents.ts
@@ -0,0 +1,40 @@
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { eligibleStudentsApi } from '../api/eligibleStudentsApi'
+import type { EligibleStudentQuery, EligibleStudentRequest } from '../types/eligibleStudentTypes'
+
+const keys = {
+ all: ['admin', 'eligible-students'] as const,
+ list: (query: EligibleStudentQuery) => [...keys.all, 'list', query] as const,
+}
+
+export function useEligibleStudents(query: EligibleStudentQuery) {
+ return useQuery({
+ queryKey: keys.list(query),
+ queryFn: ({ signal }) => eligibleStudentsApi.list(query, signal),
+ placeholderData: keepPreviousData,
+ })
+}
+
+export function useEligibleStudentMutations() {
+ const queryClient = useQueryClient()
+ const refresh = () => queryClient.invalidateQueries({ queryKey: keys.all })
+ return {
+ create: useMutation({
+ mutationFn: (values: EligibleStudentRequest) => eligibleStudentsApi.create(values),
+ onSuccess: refresh,
+ }),
+ update: useMutation({
+ mutationFn: ({ id, values }: { id: string; values: EligibleStudentRequest }) =>
+ eligibleStudentsApi.update(id, values),
+ onSuccess: refresh,
+ }),
+ remove: useMutation({
+ mutationFn: (id: string) => eligibleStudentsApi.remove(id),
+ onSuccess: refresh,
+ }),
+ importFile: useMutation({
+ mutationFn: (file: File) => eligibleStudentsApi.importFile(file),
+ onSuccess: refresh,
+ }),
+ }
+}
diff --git a/src/features/eligible-students/pages/EligibleStudentsPage.tsx b/src/features/eligible-students/pages/EligibleStudentsPage.tsx
new file mode 100644
index 0000000..af23a3a
--- /dev/null
+++ b/src/features/eligible-students/pages/EligibleStudentsPage.tsx
@@ -0,0 +1,175 @@
+import { useState } from 'react'
+import { useNotifications } from '../../../app/providers/NotificationProvider'
+import { mapApiError } from '../../../shared/api/apiErrorMapper'
+import { PaginationBar } from '../../../shared/components/data/PaginationBar'
+import { SearchInput } from '../../../shared/components/data/SearchInput'
+import { EmptyState } from '../../../shared/components/feedback/EmptyState'
+import { ErrorState } from '../../../shared/components/feedback/ErrorState'
+import { LoadingBoundary } from '../../../shared/components/feedback/LoadingBoundary'
+import { PageHeader } from '../../../shared/components/layout/PageHeader'
+import { SectionCard } from '../../../shared/components/layout/SectionCard'
+import { ConfirmDialog } from '../../../shared/components/overlays/ConfirmDialog'
+import { Button } from '../../../shared/components/ui/Button'
+import { useDebouncedValue } from '../../../shared/hooks/useDebouncedValue'
+import { EligibleStudentForm } from '../components/EligibleStudentForm'
+import { EligibleStudentImportPanel } from '../components/EligibleStudentImportPanel'
+import { EligibleStudentsTable } from '../components/EligibleStudentsTable'
+import { useEligibleStudentMutations, useEligibleStudents } from '../hooks/useEligibleStudents'
+import { ELIGIBLE_STUDENTS_PAGE_SIZE } from '../types/eligibleStudentTypes'
+import type { EligibleStudent, EligibleStudentRequest } from '../types/eligibleStudentTypes'
+
+export function EligibleStudentsPage() {
+ const { notify } = useNotifications()
+ const [search, setSearchValue] = useState('')
+ const [page, setPage] = useState(0)
+ const debouncedSearch = useDebouncedValue(search, 300)
+ const [editing, setEditing] = useState(null)
+ const [deleting, setDeleting] = useState(null)
+
+ const setSearch = (value: string) => {
+ setSearchValue(value)
+ setPage(0)
+ }
+
+ const query = useEligibleStudents({
+ page,
+ size: ELIGIBLE_STUDENTS_PAGE_SIZE,
+ sort: 'indexNumber,asc',
+ search: debouncedSearch,
+ })
+ const mutations = useEligibleStudentMutations()
+ const pending = mutations.create.isPending || mutations.update.isPending || mutations.remove.isPending
+
+ const save = async (values: EligibleStudentRequest) => {
+ const item =
+ editing === 'new'
+ ? await mutations.create.mutateAsync(values)
+ : await mutations.update.mutateAsync({ id: editing!.id, values })
+ notify({
+ tone: 'success',
+ title: editing === 'new' ? 'Eligible student added' : 'Eligible student updated',
+ message: `${item.fullName} was saved.`,
+ })
+ setEditing(null)
+ }
+
+ const remove = async () => {
+ if (!deleting) return
+ try {
+ await mutations.remove.mutateAsync(deleting.id)
+ notify({
+ tone: 'success',
+ title: 'Eligible student removed',
+ message: `${deleting.fullName} was removed from the roster.`,
+ })
+ setDeleting(null)
+ } catch (error) {
+ const mapped = mapApiError(error, 'protected')
+ notify({ tone: 'error', title: 'Unable to remove eligible student', message: mapped.message })
+ }
+ }
+
+ const items = query.data?.items ?? []
+ const mappedError = query.isError ? mapApiError(query.error, 'protected') : null
+
+ return (
+
+
+
+
+
+
+
+
Roster
+ person_add}
+ onClick={() => setEditing('new')}
+ >
+ Add Student
+
+
+ setSearch(event.target.value)}
+ placeholder="Search by index number, name, or email"
+ value={search}
+ />
+
+ {mappedError ? (
+ void query.refetch()}
+ title="Eligible students unavailable"
+ />
+ ) : items.length === 0 ? (
+
+ ) : (
+ <>
+
+ {query.data ? (
+
+ ) : null}
+ >
+ )}
+
+
+
+ {editing ? (
+ setEditing(null)}
+ onSubmit={save}
+ />
+ ) : null}
+
+ {deleting ? (
+ setDeleting(null)}
+ title="Remove Eligible Student"
+ >
+
+ Remove {deleting.fullName} ({deleting.indexNumber}) from the roster?
+ They won’t be able to register.
+
+
+ setDeleting(null)}
+ variant="secondary"
+ >
+ Cancel
+
+ void remove()}>
+ Remove Student
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/features/eligible-students/schemas/eligibleStudentSchemas.ts b/src/features/eligible-students/schemas/eligibleStudentSchemas.ts
new file mode 100644
index 0000000..8526d3b
--- /dev/null
+++ b/src/features/eligible-students/schemas/eligibleStudentSchemas.ts
@@ -0,0 +1,54 @@
+import { z } from 'zod'
+
+const timestampSchema = z.string().datetime({ offset: true })
+
+export const eligibleStudentSchema = z
+ .object({
+ id: z.string().uuid(),
+ indexNumber: z.string().min(1).max(32),
+ universityEmail: z.string().email().max(254),
+ fullName: z.string().min(1).max(160),
+ academicLevel: z.union([z.literal(3), z.literal(4)]),
+ active: z.boolean(),
+ registered: z.boolean(),
+ createdAt: timestampSchema,
+ updatedAt: timestampSchema,
+ })
+ .strict()
+
+export const pageMetadataSchema = z
+ .object({
+ page: z.number().int().nonnegative(),
+ size: z.number().int().min(1).max(500),
+ totalElements: z.number().int().nonnegative(),
+ totalPages: z.number().int().nonnegative(),
+ sort: z.string(),
+ })
+ .strict()
+
+export const eligibleStudentPagedResponseSchema = z
+ .object({ items: z.array(eligibleStudentSchema), page: pageMetadataSchema })
+ .strict()
+
+export const eligibleStudentImportResultSchema = z
+ .object({
+ totalRows: z.number().int().nonnegative(),
+ importedCount: z.number().int().nonnegative(),
+ skippedCount: z.number().int().nonnegative(),
+ errors: z.array(z.object({ row: z.number().int(), message: z.string() })),
+ })
+ .strict()
+
+const indexNumberPattern = /^[A-Za-z]{2}\/[0-9]{4}\/[0-9]{5}$/
+
+export const eligibleStudentFormSchema = z.object({
+ indexNumber: z
+ .string()
+ .trim()
+ .regex(indexNumberPattern, 'Use the format CS/2022/00123.'),
+ universityEmail: z.string().trim().email('Enter a valid email address.').max(254),
+ fullName: z.string().trim().min(1, 'Full name is required.').max(160),
+ academicLevel: z.union([z.literal('3'), z.literal('4')], {
+ errorMap: () => ({ message: 'Select academic level 3 or 4.' }),
+ }),
+})
diff --git a/src/features/eligible-students/types/eligibleStudentTypes.ts b/src/features/eligible-students/types/eligibleStudentTypes.ts
new file mode 100644
index 0000000..54966e5
--- /dev/null
+++ b/src/features/eligible-students/types/eligibleStudentTypes.ts
@@ -0,0 +1,53 @@
+export const ELIGIBLE_STUDENTS_PAGE_SIZE = 10
+
+export type EligibleStudent = {
+ id: string
+ indexNumber: string
+ universityEmail: string
+ fullName: string
+ academicLevel: 3 | 4
+ active: boolean
+ registered: boolean
+ createdAt: string
+ updatedAt: string
+}
+
+export type EligibleStudentQuery = {
+ page: number
+ size: number
+ sort: string
+ search: string
+}
+
+export type PageMetadata = {
+ page: number
+ size: number
+ totalElements: number
+ totalPages: number
+ sort: string
+}
+
+export type PagedResponse = { items: T[]; page: PageMetadata }
+
+export type EligibleStudentFormValues = {
+ indexNumber: string
+ universityEmail: string
+ fullName: string
+ academicLevel: '3' | '4' | ''
+}
+
+export type EligibleStudentRequest = {
+ indexNumber: string
+ universityEmail: string
+ fullName: string
+ academicLevel: 3 | 4
+}
+
+export type EligibleStudentImportRowError = { row: number; message: string }
+
+export type EligibleStudentImportResult = {
+ totalRows: number
+ importedCount: number
+ skippedCount: number
+ errors: EligibleStudentImportRowError[]
+}
diff --git a/src/features/home/pages/HomePage.tsx b/src/features/home/pages/HomePage.tsx
index 942b2c5..415fad3 100644
--- a/src/features/home/pages/HomePage.tsx
+++ b/src/features/home/pages/HomePage.tsx
@@ -72,7 +72,6 @@ export function HomePage() {
-
Secure role-based access
Select your role
diff --git a/src/features/home/styles/gateway.css b/src/features/home/styles/gateway.css
index 5cc269c..4e68546 100644
--- a/src/features/home/styles/gateway.css
+++ b/src/features/home/styles/gateway.css
@@ -288,8 +288,8 @@
overflow-x: hidden;
display: flex;
flex-direction: column;
- gap: clamp(24px, 4vw, 40px);
- padding: clamp(48px, 6vw, 88px);
+ gap: clamp(16px, 2.5vw, 28px);
+ padding: clamp(28px, 4vw, 56px);
background: linear-gradient(180deg, var(--gateway-surface) 0%, var(--gateway-bg) 100%);
}
@@ -303,14 +303,14 @@
.gateway-v2-access-header {
display: grid;
justify-items: start;
- gap: 14px;
+ gap: 10px;
flex-shrink: 0;
}
.gateway-v2-access-header h2 {
margin: 0;
color: var(--sidebar-text);
- font-size: clamp(2rem, 4vw, 3.2rem);
+ font-size: clamp(1.7rem, 3.2vw, 2.6rem);
font-weight: 900;
line-height: 1.05;
text-transform: uppercase;
@@ -337,13 +337,13 @@
}
.gateway-v2-card {
- min-height: 170px;
+ min-height: 140px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
- gap: 12px;
- padding: clamp(24px, 3vw, 34px);
+ gap: 10px;
+ padding: clamp(18px, 2.4vw, 26px);
background: var(--gateway-surface);
text-align: left;
transition:
@@ -360,21 +360,21 @@
}
.gateway-v2-card .material-symbols-outlined {
- width: 56px;
- height: 56px;
+ width: 48px;
+ height: 48px;
display: grid;
place-items: center;
margin-bottom: 2px;
border-radius: 50%;
background: var(--sidebar-surface);
color: var(--sidebar-accent);
- font-size: 31px;
+ font-size: 27px;
}
.gateway-v2-card h3 {
margin: 0;
color: var(--sidebar-text);
- font-size: clamp(1.55rem, 2.6vw, 2.1rem);
+ font-size: clamp(1.3rem, 2.1vw, 1.75rem);
font-weight: 800;
line-height: 1.05;
text-transform: uppercase;
@@ -471,14 +471,14 @@
min-height: auto;
height: auto;
overflow-y: visible;
- gap: 24px;
- padding: 40px 24px 64px;
+ gap: 18px;
+ padding: 28px 24px 48px;
}
.gateway-v2-card {
min-height: auto;
- gap: 16px;
- padding: 24px;
+ gap: 12px;
+ padding: 20px;
}
.gateway-v2-card .material-symbols-outlined {
diff --git a/src/features/student-auth/components/StudentCreatePasswordForm.tsx b/src/features/student-auth/components/StudentCreatePasswordForm.tsx
index 5a68215..bd335ab 100644
--- a/src/features/student-auth/components/StudentCreatePasswordForm.tsx
+++ b/src/features/student-auth/components/StudentCreatePasswordForm.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { FormField } from '../../../shared/components/forms/FormField'
-import { TextInput } from '../../../shared/components/forms/TextInput'
+import { PasswordInput } from '../../../shared/components/forms/PasswordInput'
import { Button } from '../../../shared/components/ui/Button'
import {
flattenZodErrors,
@@ -45,13 +45,12 @@ export function StudentCreatePasswordForm({
Use at least 8 characters with uppercase, lowercase, number, and special character.
-
setValues((current) => ({ ...current, newPassword: event.target.value }))
}
- type="password"
value={values.newPassword}
/>
@@ -60,13 +59,12 @@ export function StudentCreatePasswordForm({
htmlFor="student-confirm-password"
label="Confirm New Password"
>
-
setValues((current) => ({ ...current, confirmPassword: event.target.value }))
}
- type="password"
value={values.confirmPassword}
/>
diff --git a/src/features/student-auth/components/StudentLoginForm.tsx b/src/features/student-auth/components/StudentLoginForm.tsx
index c5452d7..2c4898f 100644
--- a/src/features/student-auth/components/StudentLoginForm.tsx
+++ b/src/features/student-auth/components/StudentLoginForm.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Link } from 'react-router-dom'
import { routePaths } from '../../../app/config/routePaths'
import { FormField } from '../../../shared/components/forms/FormField'
+import { PasswordInput } from '../../../shared/components/forms/PasswordInput'
import { TextInput } from '../../../shared/components/forms/TextInput'
import { Button } from '../../../shared/components/ui/Button'
import { flattenZodErrors, loginSchema, type LoginFormValues } from '../schemas/studentAuthSchemas'
@@ -42,14 +43,13 @@ export function StudentLoginForm({ isSubmitting, onSubmit }: StudentLoginFormPro
/>
-
setValues((current) => ({ ...current, password: event.target.value }))
}
placeholder="Enter your password"
- type="password"
value={values.password}
/>
diff --git a/src/features/student-auth/pages/StudentLoginPage.tsx b/src/features/student-auth/pages/StudentLoginPage.tsx
index b78a510..f451696 100644
--- a/src/features/student-auth/pages/StudentLoginPage.tsx
+++ b/src/features/student-auth/pages/StudentLoginPage.tsx
@@ -44,6 +44,12 @@ export function StudentLoginPage() {
}
>
+
+
+ arrow_back
+
+ Back
+
Login
{message ? (
diff --git a/src/features/student-auth/pages/StudentSignUpPage.tsx b/src/features/student-auth/pages/StudentSignUpPage.tsx
index 1f3bfc5..a469f31 100644
--- a/src/features/student-auth/pages/StudentSignUpPage.tsx
+++ b/src/features/student-auth/pages/StudentSignUpPage.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { useNavigate } from 'react-router-dom'
+import { Link, useNavigate } from 'react-router-dom'
import { routePaths } from '../../../app/config/routePaths'
import { mapApiError } from '../../../shared/api/apiErrorMapper'
import { authStorage } from '../../../shared/auth/authStorage'
@@ -42,6 +42,12 @@ export function StudentSignUpPage() {
title="Launch your placement profile."
>
+
+
+ arrow_back
+
+ Back
+
Student Registration
Initialize your passwordless account authorization request below.
diff --git a/src/features/student-profile/api/studentProfileEntriesApi.ts b/src/features/student-profile/api/studentProfileEntriesApi.ts
index 3e32c92..0ec20c1 100644
--- a/src/features/student-profile/api/studentProfileEntriesApi.ts
+++ b/src/features/student-profile/api/studentProfileEntriesApi.ts
@@ -6,6 +6,7 @@ import {
awardSchema,
certificateSchema,
contactLinkSchema,
+ educationSchema,
experienceSchema,
pagedResponseSchema,
} from '../schemas/profileEntrySchemas'
@@ -18,6 +19,8 @@ import type {
CertificateRequest,
ContactLink,
ContactLinkRequest,
+ Education,
+ EducationRequest,
Experience,
ExperienceRequest,
PagedResponse,
@@ -69,6 +72,10 @@ export const contactLinksApi = createCollectionApi
(
+ '/me/profile/education',
+ educationSchema,
+)
export const certificatesApi = createCollectionApi(
'/me/profile/certificates',
certificateSchema,
diff --git a/src/features/student-profile/components/EducationEditor.tsx b/src/features/student-profile/components/EducationEditor.tsx
new file mode 100644
index 0000000..944ce5a
--- /dev/null
+++ b/src/features/student-profile/components/EducationEditor.tsx
@@ -0,0 +1,149 @@
+import { useState } from 'react'
+import { mapApiError } from '../../../shared/api/apiErrorMapper'
+import { FormField } from '../../../shared/components/forms/FormField'
+import { TextInput } from '../../../shared/components/forms/TextInput'
+import { mapEducationRequest } from '../mappers/profileEntryMappers'
+import { educationFormSchema } from '../schemas/profileEntrySchemas'
+import type { Education, EducationRequest } from '../types/profileEntryTypes'
+import { ProfileEditorActions } from './ProfileEditorActions'
+
+const toMonthInput = (value: string | null) => (value ? value.slice(0, 7) : '')
+
+export function EducationEditor({
+ isPending,
+ item,
+ onCancel,
+ onSubmit,
+}: {
+ isPending: boolean
+ item?: Education
+ onCancel: () => void
+ onSubmit: (values: EducationRequest) => Promise
+}) {
+ const [values, setValues] = useState({
+ degree: item?.degree ?? '',
+ institution: item?.institution ?? '',
+ institutionUrl: item?.institutionUrl ?? '',
+ location: item?.location ?? '',
+ startDate: toMonthInput(item?.startDate ?? null),
+ endDate: toMonthInput(item?.endDate ?? null),
+ current: item?.current ?? false,
+ resultNote: item?.resultNote ?? '',
+ cvInclude: item?.cvInclude ?? true,
+ })
+ const [error, setError] = useState(null)
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ setError(null)
+ const parsed = educationFormSchema.safeParse(values)
+ if (!parsed.success) {
+ setError(parsed.error.issues[0]?.message ?? 'Check the entered details.')
+ return
+ }
+ try {
+ await onSubmit(mapEducationRequest(parsed.data))
+ } catch (reason) {
+ setError(mapApiError(reason, 'protected').message)
+ }
+ }
+ return (
+
+ )
+}
diff --git a/src/features/student-profile/components/ProfileCollectionSection.tsx b/src/features/student-profile/components/ProfileCollectionSection.tsx
index 90efdd2..5d17030 100644
--- a/src/features/student-profile/components/ProfileCollectionSection.tsx
+++ b/src/features/student-profile/components/ProfileCollectionSection.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
+import { useState } from 'react'
import { mapApiError } from '../../../shared/api/apiErrorMapper'
import { PaginationBar } from '../../../shared/components/data/PaginationBar'
import { SearchInput } from '../../../shared/components/data/SearchInput'
@@ -44,58 +45,86 @@ export function ProfileCollectionSection({
title: string
}) {
const mappedError = error ? mapApiError(error, 'protected') : null
+ const [isOpen, setIsOpen] = useState(true)
+ const slug = title.replaceAll(' ', '-').toLowerCase()
+ const headingId = `${slug}-title`
+ const bodyId = `${slug}-body`
return (
-
-
{title}
-
{description}
+
+
setIsOpen((current) => !current)}
+ type="button"
+ >
+
+
+
+
+
+
{title}
+
{description}
+
{addLabel}
-
{savedTitle}
-
onSearchChange(event.target.value)}
- placeholder={searchLabel}
- value={search}
- />
- {isFetching && !isPending ? (
-
- Updating results…
-
- ) : null}
- {isPending ? (
-
-
-
+ {isOpen ? (
+
+
{savedTitle}
+
onSearchChange(event.target.value)}
+ placeholder={searchLabel}
+ value={search}
+ />
+ {isFetching && !isPending ? (
+
+ Updating results…
+
+ ) : null}
+ {isPending ? (
+
+
+
+
+ ) : null}
+ {mappedError ? (
+
+ ) : null}
+ {!isPending && !mappedError ? children : null}
+ {page && page.totalPages > 0 ? (
+
+ ) : null}
) : null}
- {mappedError ? (
-
- ) : null}
- {!isPending && !mappedError ? children : null}
- {page && page.totalPages > 0 ? (
-
- ) : null}
)
}
diff --git a/src/features/student-profile/components/ProfileSections.tsx b/src/features/student-profile/components/ProfileSections.tsx
index 696e1c0..5cbfba7 100644
--- a/src/features/student-profile/components/ProfileSections.tsx
+++ b/src/features/student-profile/components/ProfileSections.tsx
@@ -15,6 +15,8 @@ import {
useCertificateMutations,
useContactLinkMutations,
useContactLinks,
+ useEducation,
+ useEducationMutations,
useExperience,
useExperienceMutations,
} from '../hooks/useProfileEntries'
@@ -28,6 +30,8 @@ import type {
CertificateRequest,
ContactLink,
ContactLinkRequest,
+ Education,
+ EducationRequest,
Experience,
ExperienceRequest,
ProfileCollectionQuery,
@@ -38,6 +42,7 @@ import { ActivityEditor } from './ActivityEditor'
import { AwardEditor } from './AwardEditor'
import { CertificateEditor } from './CertificateEditor'
import { ContactLinkEditor } from './ContactLinkEditor'
+import { EducationEditor } from './EducationEditor'
import { ExperienceEditor } from './ExperienceEditor'
import { ProfileCollectionEmpty, ProfileCollectionSection } from './ProfileCollectionSection'
import { ProfileEntryCard } from './ProfileEntryCard'
@@ -244,6 +249,108 @@ export function ProfessionalLinksSection() {
)
}
+export function EducationSection() {
+ const state = useProfileSectionState('startDate,desc')
+ const query = useEducation(state.query)
+ const mutations = useEducationMutations()
+ const { notify } = useNotifications()
+ const [editing, setEditing] = useState
(null)
+ const [deleting, setDeleting] = useState(null)
+ const pending =
+ mutations.create.isPending || mutations.update.isPending || mutations.remove.isPending
+ const save = async (values: EducationRequest) => {
+ const item =
+ editing === 'new'
+ ? await mutations.create.mutateAsync(values)
+ : await mutations.update.mutateAsync({ id: editing!.id, version: editing!.version, values })
+ notify({
+ tone: 'success',
+ title: editing === 'new' ? 'Education added' : 'Education updated',
+ message: `${item.degree} was saved.`,
+ })
+ setEditing(null)
+ }
+ const remove = async () => {
+ if (!deleting) return
+ try {
+ await mutations.remove.mutateAsync({ id: deleting.id, version: deleting.version })
+ afterDelete(query.data?.items ?? [], state.page, state.setPage)
+ setDeleting(null)
+ notify({ tone: 'success', title: 'Education deleted', message: 'The entry was removed.' })
+ } catch (error) {
+ notifyFailure(notify, error, 'Unable to delete Education entry')
+ }
+ }
+ const items = query.data?.items ?? []
+ return (
+ <>
+ setEditing('new')}
+ onPageChange={state.setPage}
+ onRetry={() => void query.refetch()}
+ onSearchChange={state.setSearch}
+ page={query.data?.page}
+ savedTitle="Saved Education"
+ search={state.search}
+ searchLabel="Search education entries"
+ title="Education"
+ >
+ {items.length === 0 ? (
+ setEditing('new')} search={state.search} title="Education" />
+ ) : (
+
+ {items.map((item) => (
+
setDeleting(item)}
+ onEdit={() => setEditing(item)}
+ />
+ }
+ cvInclude={item.cvInclude}
+ key={item.id}
+ subtitle={`${item.institution}${item.location ? ` · ${item.location}` : ''}${item.startDate ? ` · ${item.startDate} – ${item.current ? 'Present' : item.endDate ?? ''}` : ''}`}
+ title={item.degree}
+ >
+ {item.resultNote ? {item.resultNote}
: null}
+
+ ))}
+
+ )}
+
+ {editing ? (
+ setEditing(null)}
+ title={editing === 'new' ? 'Add Education' : 'Edit Education'}
+ >
+ setEditing(null)}
+ onSubmit={save}
+ />
+
+ ) : null}
+ {deleting ? (
+ setDeleting(null)}
+ onConfirm={() => void remove()}
+ />
+ ) : null}
+ >
+ )
+}
+
export function CertificatesSection({ evidencePolicy }: { evidencePolicy?: FileUploadConstraint }) {
const state = useProfileSectionState('issueDate,desc')
const query = useCertificates(state.query)
diff --git a/src/features/student-profile/hooks/useProfileEntries.ts b/src/features/student-profile/hooks/useProfileEntries.ts
index 08395d4..abe0f77 100644
--- a/src/features/student-profile/hooks/useProfileEntries.ts
+++ b/src/features/student-profile/hooks/useProfileEntries.ts
@@ -4,6 +4,7 @@ import {
awardsApi,
certificatesApi,
contactLinksApi,
+ educationApi,
experienceApi,
} from '../api/studentProfileEntriesApi'
import type {
@@ -15,6 +16,8 @@ import type {
CertificateRequest,
ContactLink,
ContactLinkRequest,
+ Education,
+ EducationRequest,
Experience,
ExperienceRequest,
PagedResponse,
@@ -68,6 +71,8 @@ function useCollectionMutations(
export const useContactLinks = (query: ProfileCollectionQuery) =>
useCollection('contact-links', contactLinksApi, query)
+export const useEducation = (query: ProfileCollectionQuery) =>
+ useCollection('education', educationApi, query)
export const useCertificates = (query: ProfileCollectionQuery) =>
useCollection('certificates', certificatesApi, query)
export const useAwards = (query: ProfileCollectionQuery) =>
@@ -78,6 +83,8 @@ export const useExperience = (query: ProfileCollectionQuery) =>
useCollection('experience', experienceApi, query)
export const useContactLinkMutations = () =>
useCollectionMutations('contact-links', contactLinksApi)
+export const useEducationMutations = () =>
+ useCollectionMutations('education', educationApi)
export const useCertificateMutations = () =>
useCollectionMutations('certificates', certificatesApi)
export const useAwardMutations = () =>
diff --git a/src/features/student-profile/mappers/profileEntryMappers.ts b/src/features/student-profile/mappers/profileEntryMappers.ts
index 7113db8..b37b642 100644
--- a/src/features/student-profile/mappers/profileEntryMappers.ts
+++ b/src/features/student-profile/mappers/profileEntryMappers.ts
@@ -7,11 +7,26 @@ import type {
CertificateRequest,
ContactLinkFormValues,
ContactLinkRequest,
+ EducationFormValues,
+ EducationRequest,
ExperienceFormValues,
ExperienceRequest,
} from '../types/profileEntryTypes'
const nullable = (value: string) => value.trim() || null
+const nullableMonth = (value: string) => (value.trim() ? `${value.trim()}-01` : null)
+
+export const mapEducationRequest = (value: EducationFormValues): EducationRequest => ({
+ degree: value.degree.trim(),
+ institution: value.institution.trim(),
+ institutionUrl: nullable(value.institutionUrl),
+ location: nullable(value.location),
+ startDate: nullableMonth(value.startDate),
+ endDate: value.current ? null : nullableMonth(value.endDate),
+ current: value.current,
+ resultNote: nullable(value.resultNote),
+ cvInclude: value.cvInclude,
+})
export const mapContactLinkRequest = (value: ContactLinkFormValues): ContactLinkRequest => ({
label: value.label.trim(),
diff --git a/src/features/student-profile/pages/StudentProfilePage.tsx b/src/features/student-profile/pages/StudentProfilePage.tsx
index bc52939..1ba461b 100644
--- a/src/features/student-profile/pages/StudentProfilePage.tsx
+++ b/src/features/student-profile/pages/StudentProfilePage.tsx
@@ -8,6 +8,7 @@ import {
ActivitiesSection,
AwardsSection,
CertificatesSection,
+ EducationSection,
ExperienceSection,
ProfessionalLinksSection,
} from '../components/ProfileSections'
@@ -73,6 +74,7 @@ export function StudentProfilePage() {
) : null}
+
diff --git a/src/features/student-profile/schemas/profileEntrySchemas.ts b/src/features/student-profile/schemas/profileEntrySchemas.ts
index 2735442..cf5cfe4 100644
--- a/src/features/student-profile/schemas/profileEntrySchemas.ts
+++ b/src/features/student-profile/schemas/profileEntrySchemas.ts
@@ -31,6 +31,19 @@ export const contactLinkSchema = z
displayOrder: z.number().int().nonnegative(),
})
.strict()
+export const educationSchema = z
+ .object({
+ ...baseResponse,
+ degree: z.string().min(1).max(200),
+ institution: z.string().min(1).max(200),
+ institutionUrl: safeWebUrlSchema.nullable(),
+ location: z.string().nullable(),
+ startDate: nullableDateSchema,
+ endDate: nullableDateSchema,
+ current: z.boolean(),
+ resultNote: z.string().nullable(),
+ })
+ .strict()
export const certificateSchema = z
.object({
...baseResponse,
@@ -91,6 +104,33 @@ export const contactLinkFormSchema = z.object({
displayOrder: z.string().regex(/^\d+$/, 'Display Order must be zero or greater.'),
cvInclude: z.boolean(),
})
+const monthOnlySchema = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'Use the MM/YYYY format.')
+export const educationFormSchema = z
+ .object({
+ degree: z.string().trim().min(1, 'Degree / Field of Study is required.').max(200),
+ institution: z.string().trim().min(1, 'School / Institution is required.').max(200),
+ institutionUrl: optionalSafeUrl,
+ location: nullableText,
+ startDate: z.union([z.literal(''), monthOnlySchema]),
+ endDate: z.union([z.literal(''), monthOnlySchema]),
+ current: z.boolean(),
+ resultNote: z.string().trim().max(500),
+ cvInclude: z.boolean(),
+ })
+ .superRefine((value, context) => {
+ if (value.current && value.endDate)
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['endDate'],
+ message: 'Currently studying entries cannot have an End Date.',
+ })
+ if (value.startDate && value.endDate && value.endDate < value.startDate)
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['endDate'],
+ message: 'End Date cannot be before Start Date.',
+ })
+ })
export const certificateFormSchema = z.object({
title: z.string().trim().min(1, 'Title is required.').max(200),
issuer: z.string().trim().min(1, 'Issuer is required.').max(200),
diff --git a/src/features/student-profile/types/profileEntryTypes.ts b/src/features/student-profile/types/profileEntryTypes.ts
index f4563ec..afad169 100644
--- a/src/features/student-profile/types/profileEntryTypes.ts
+++ b/src/features/student-profile/types/profileEntryTypes.ts
@@ -3,7 +3,7 @@ import type { FileAsset } from './profileFileTypes'
export const PROFILE_SECTION_PAGE_SIZE = 5
export type ProfileCollectionKind =
- 'contact-links' | 'certificates' | 'awards' | 'activities' | 'experience'
+ 'contact-links' | 'education' | 'certificates' | 'awards' | 'activities' | 'experience'
export type ProfileCollectionQuery = {
page: number
@@ -35,6 +35,16 @@ export type ContactLink = VersionedProfileEntry & {
url: string
displayOrder: number
}
+export type Education = VersionedProfileEntry & {
+ degree: string
+ institution: string
+ institutionUrl: string | null
+ location: string | null
+ startDate: string | null
+ endDate: string | null
+ current: boolean
+ resultNote: string | null
+}
export type Certificate = VersionedProfileEntry & {
title: string
issuer: string
@@ -71,6 +81,17 @@ export type ContactLinkFormValues = {
displayOrder: string
cvInclude: boolean
}
+export type EducationFormValues = {
+ degree: string
+ institution: string
+ institutionUrl: string
+ location: string
+ startDate: string
+ endDate: string
+ current: boolean
+ resultNote: string
+ cvInclude: boolean
+}
export type CertificateFormValues = {
title: string
issuer: string
@@ -110,6 +131,17 @@ export type ContactLinkRequest = {
displayOrder: number
cvInclude: boolean
}
+export type EducationRequest = {
+ degree: string
+ institution: string
+ institutionUrl: string | null
+ location: string | null
+ startDate: string | null
+ endDate: string | null
+ current: boolean
+ resultNote: string | null
+ cvInclude: boolean
+}
export type CertificateRequest = {
title: string
issuer: string
diff --git a/src/index.css b/src/index.css
index 33c7b7e..589be7f 100644
--- a/src/index.css
+++ b/src/index.css
@@ -715,6 +715,25 @@ textarea:focus-visible {
padding: 0;
}
+.auth-back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ justify-self: start;
+ font-size: 0.94rem;
+ font-weight: 500;
+ color: var(--color-text-muted, var(--color-text));
+ text-decoration: none;
+}
+
+.auth-back-link:hover {
+ color: var(--primary);
+}
+
+.auth-back-link .material-symbols-outlined {
+ font-size: 20px;
+}
+
.auth-form-card h1,
.auth-centered-card h1 {
margin: 0;
@@ -1445,12 +1464,55 @@ body.admin-mobile-drawer-open {
gap: 16px;
}
+.profile-section-heading-main {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ min-width: 0;
+ flex: 1;
+}
+
.profile-section-heading h2,
.profile-section-heading p,
.profile-form-alert p {
margin: 0;
}
+.profile-section-toggle {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ margin-top: 2px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ background: var(--color-card);
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
+}
+
+.profile-section-toggle:hover {
+ background: var(--surface-container-high);
+ color: var(--color-text);
+ border-color: var(--color-text-muted);
+}
+
+.profile-section-toggle svg {
+ transition: transform 0.2s ease;
+}
+
+.profile-section-toggle[aria-expanded='false'] svg {
+ transform: rotate(-90deg);
+}
+
+.profile-section-body {
+ display: grid;
+ gap: 18px;
+}
+
.profile-unsaved-indicator {
flex: none;
padding: 6px 10px;
@@ -2111,6 +2173,47 @@ p {
outline: none;
}
+.password-input-wrap {
+ position: relative;
+}
+
+.password-input-wrap .input {
+ padding-right: 44px;
+}
+
+.password-toggle-button {
+ position: absolute;
+ top: 50%;
+ right: 4px;
+ transform: translateY(-50%);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: background-color var(--motion-duration-fast) var(--motion-standard),
+ color var(--motion-duration-fast) var(--motion-standard);
+}
+
+.password-toggle-button:hover {
+ background: var(--surface-container-high);
+ color: var(--color-text);
+}
+
+.password-toggle-button:focus-visible {
+ outline: 2px solid var(--focus-ring);
+ outline-offset: 2px;
+}
+
+.password-toggle-button .material-symbols-outlined {
+ font-size: 20px;
+}
+
.error-text {
color: var(--color-danger);
font-size: 0.9rem;
@@ -2149,6 +2252,15 @@ p {
line-height: 1.5;
}
+.inline-alert-success {
+ border: 1px solid color-mix(in srgb, var(--color-success) 35%, var(--color-border));
+ border-radius: var(--radius-md);
+ background: var(--success-bg);
+ color: var(--color-text);
+ padding: 12px 14px;
+ line-height: 1.5;
+}
+
.auth-test-credentials {
display: grid;
gap: 4px;
@@ -2232,6 +2344,38 @@ p {
overflow-x: auto;
}
+.eligible-students-import-panel {
+ display: grid;
+ gap: 14px;
+}
+
+.eligible-students-import-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+}
+
+.eligible-students-import-errors {
+ margin: 8px 0 0;
+ padding-left: 20px;
+}
+
+.eligible-students-import-errors li {
+ font-size: 0.9rem;
+}
+
+.eligible-students-list-card {
+ display: grid;
+ gap: 16px;
+}
+
+.eligible-students-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
table {
width: 100%;
border-collapse: collapse;
diff --git a/src/mocks/handlers/studentHandlers.ts b/src/mocks/handlers/studentHandlers.ts
index f82ce03..e2530a5 100644
--- a/src/mocks/handlers/studentHandlers.ts
+++ b/src/mocks/handlers/studentHandlers.ts
@@ -8,6 +8,8 @@ import type {
CertificateRequest,
ContactLink,
ContactLinkRequest,
+ Education,
+ EducationRequest,
Experience,
ExperienceRequest,
VersionedProfileEntry,
@@ -53,6 +55,7 @@ function baseEntry(id = nextId(), version = 1) {
type MockProfileState = {
profile: StudentProfileResponseDto
contactLinks: ContactLink[]
+ education: Education[]
certificates: Certificate[]
awards: Award[]
activities: Activity[]
@@ -106,6 +109,20 @@ function createInitialState(): MockProfileState {
cvInclude: false,
},
] satisfies ContactLink[],
+ education: [
+ {
+ ...baseEntry('15000000-0000-4000-8000-000000000001'),
+ degree: 'Bachelor of Computer Science',
+ institution: 'University of Ruhuna',
+ institutionUrl: 'https://ruh.ac.lk',
+ location: 'Sri Lanka',
+ startDate: '2022-01-01',
+ endDate: null,
+ current: true,
+ resultNote: 'Current GPA - 3.74 / 4.00',
+ cvInclude: true,
+ },
+ ] satisfies Education[],
certificates: [
{
...baseEntry('20000000-0000-4000-8000-000000000001'),
@@ -310,6 +327,28 @@ const contactHandlers = collectionHandlers<
cvInclude: body.cvInclude ?? previous?.cvInclude ?? true,
}),
})
+const educationHandlers = collectionHandlers<
+ Education,
+ EducationRequest & Record
+>({
+ path: `${apiBase}/me/profile/education`,
+ get: () => state.education,
+ set: (items) => {
+ state.education = items
+ },
+ searchable: (item) => `${item.degree} ${item.institution}`,
+ build: (body, previous) => ({
+ degree: body.degree ?? previous!.degree,
+ institution: body.institution ?? previous!.institution,
+ institutionUrl: body.institutionUrl ?? null,
+ location: body.location ?? null,
+ startDate: body.startDate ?? null,
+ endDate: body.current ? null : (body.endDate ?? null),
+ current: body.current ?? previous?.current ?? false,
+ resultNote: body.resultNote ?? null,
+ cvInclude: body.cvInclude ?? previous?.cvInclude ?? true,
+ }),
+})
const certificateHandlers = collectionHandlers<
Certificate,
CertificateRequest & Record
@@ -467,6 +506,7 @@ export const studentHandlers = [
return HttpResponse.json(state.profile)
}),
...contactHandlers,
+ ...educationHandlers,
...certificateHandlers,
http.put(`${apiBase}/me/profile/certificates/:id/evidence`, async ({ params, request }) => {
const index = state.certificates.findIndex((item) => item.id === params.id)
diff --git a/src/shared/components/forms/PasswordInput.tsx b/src/shared/components/forms/PasswordInput.tsx
new file mode 100644
index 0000000..42ea1bb
--- /dev/null
+++ b/src/shared/components/forms/PasswordInput.tsx
@@ -0,0 +1,29 @@
+import { forwardRef, useId, useState } from 'react'
+import { TextInput, type TextInputProps } from './TextInput'
+
+export const PasswordInput = forwardRef(function PasswordInput(
+ { id, ...props },
+ ref,
+) {
+ const [visible, setVisible] = useState(false)
+ const generatedId = useId()
+ const inputId = id ?? generatedId
+
+ return (
+
+
+ setVisible((current) => !current)}
+ type="button"
+ >
+
+ {visible ? 'visibility_off' : 'visibility'}
+
+
+
+ )
+})
diff --git a/src/shared/components/overlays/LogoutConfirmDialog.tsx b/src/shared/components/overlays/LogoutConfirmDialog.tsx
new file mode 100644
index 0000000..db945bd
--- /dev/null
+++ b/src/shared/components/overlays/LogoutConfirmDialog.tsx
@@ -0,0 +1,36 @@
+import { useState } from 'react'
+import { Button } from '../ui/Button'
+import { ConfirmDialog } from './ConfirmDialog'
+
+export function LogoutConfirmDialog({
+ onClose,
+ onConfirm,
+}: {
+ onClose: () => void
+ onConfirm: () => Promise
+}) {
+ const [isPending, setIsPending] = useState(false)
+
+ const confirm = async () => {
+ setIsPending(true)
+ try {
+ await onConfirm()
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+
+ Are you sure you want to log out?
+
+
+ Cancel
+
+ void confirm()}>
+ Log Out
+
+
+
+ )
+}