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.

+ + {deleting ? ( + setDeleting(null)} + title="Remove upload" + > +

+ Remove {deleting.originalFilename}? This cannot be undone. +

+ {deleteUpload.isError ? ( +

+ {mapApiError(deleteUpload.error, 'protected').message} +

+ ) : null} +
+ + +
+
+ ) : 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() { } >
+ + + 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 ( + +
+ {formError ? ( +
+ {formError} +
+ ) : null} + + + update('indexNumber', event.target.value)} + placeholder="e.g., CS/2022/00123" + ref={indexRef} + value={values.indexNumber} + /> + + + + update('universityEmail', event.target.value)} + placeholder="e.g., student@dcs.ruh.ac.lk" + type="email" + value={values.universityEmail} + /> + + + + update('fullName', event.target.value)} + placeholder="e.g., Nimal Perera" + value={values.fullName} + /> + + + + + + +
+ + +
+
+
+ ) +} 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} + /> + +
+ {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 ( +
+ + + + + + + + + + + + + + {items.map((student) => ( + + + + + + + + + ))} + +
Eligible student roster
Index NumberFull NameUniversity EmailLevelStatusActions
{student.indexNumber}{student.fullName}{student.universityEmail}{student.academicLevel} + + {student.registered ? 'Registered' : 'Not registered'} + + + + +
+
+ ) +} 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

+ +
+ 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. +

+
+ + +
+
+ ) : 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() { } >
+ + + 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." >
+ + + 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 ( +
+ {error ? ( +
+ {error} +
+ ) : null} + + setValues({ ...values, degree: event.target.value })} + placeholder="Enter Degree / Field Of Study / Exchange Semester" + required + value={values.degree} + /> + +
+ + setValues({ ...values, institution: event.target.value })} + placeholder="Enter school / university" + required + value={values.institution} + /> + + + setValues({ ...values, institutionUrl: event.target.value })} + placeholder="https://" + type="url" + value={values.institutionUrl} + /> + +
+
+ + setValues({ ...values, startDate: event.target.value })} + type="month" + value={values.startDate} + /> + + + setValues({ ...values, endDate: event.target.value })} + type="month" + value={values.current ? '' : values.endDate} + /> + + + setValues({ ...values, location: event.target.value })} + placeholder="City, Country" + value={values.location} + /> + +
+ + + setValues({ ...values, resultNote: event.target.value })} + placeholder="e.g. Current GPA - 3.74 / 4.00" + value={values.resultNote} + /> + + + + + ) +} 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}

+
+ +
+

{title}

+

{description}

+
-

{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 ( +
+ + +
+ ) +}) 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?

+
+ + +
+
+ ) +}