Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/config/routePaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/app/layouts/AdminLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down
14 changes: 13 additions & 1 deletion src/app/layouts/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<HTMLButtonElement | null>(null)
const sidebarRef = useRef<HTMLElement | null>(null)
const firstNavigationItemRef = useRef<HTMLAnchorElement | null>(null)
Expand Down Expand Up @@ -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}
/>
Expand Down Expand Up @@ -153,6 +155,16 @@ export function AdminLayout() {
</div>
</div>
</div>

{isLogoutConfirmOpen ? (
<LogoutConfirmDialog
onClose={() => setIsLogoutConfirmOpen(false)}
onConfirm={async () => {
await auth.logout()
setIsLogoutConfirmOpen(false)
}}
/>
) : null}
</section>
)
}
3 changes: 3 additions & 0 deletions src/app/layouts/StudentLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}')
Expand Down
14 changes: 13 additions & 1 deletion src/app/layouts/StudentLayout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<HTMLButtonElement | null>(null)
const sidebarRef = useRef<HTMLElement | null>(null)
const firstNavigationItemRef = useRef<HTMLAnchorElement | null>(null)
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -185,6 +187,16 @@ export function StudentLayout() {
</div>
</div>
</div>

{isLogoutConfirmOpen ? (
<LogoutConfirmDialog
onClose={() => setIsLogoutConfirmOpen(false)}
onConfirm={async () => {
await auth.logout()
setIsLogoutConfirmOpen(false)
}}
/>
) : null}
</section>
)
}
1 change: 1 addition & 0 deletions src/app/layouts/admin/adminNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]
6 changes: 6 additions & 0 deletions src/app/router/lazyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
)
5 changes: 5 additions & 0 deletions src/app/router/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
InternshipManagementPage,
CandidateFilteringPage,
ShortlistsPage,
EligibleStudentsPage,
AdminForgotPasswordPage,
AdminLoginPage,
AdminVerifyResetOtpPage,
Expand Down Expand Up @@ -270,6 +271,10 @@ export const routes: RouteObject[] = [
path: routePaths.adminShortlists,
element: withSuspense(<ShortlistsPage />, <ShortlistExportSkeleton />),
},
{
path: routePaths.adminEligibleStudents,
element: withSuspense(<EligibleStudentsPage />),
},
],
},
...fallbackRoutes,
Expand Down
6 changes: 6 additions & 0 deletions src/features/academic-ledger/api/academicLedgerApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,10 @@ export const academicLedgerApi = {
)
return ledgerCommitResponseSchema.parse(response)
},

async remove(uploadId: string) {
await httpClient<void>(`/admin/academic-ledger/uploads/${encodeURIComponent(uploadId)}`, {
method: 'DELETE',
})
},
}
23 changes: 10 additions & 13 deletions src/features/academic-ledger/components/LedgerUploadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -68,20 +68,17 @@ export function LedgerUploadPanel({
return (
<section aria-labelledby="ledger-upload-title" className="section-card ledger-upload-panel">
<div className="ledger-upload-heading">
<h2 id="ledger-upload-title">Upload academic records here.</h2>
<p>
Select one official UTF-8 CSV ledger file. The file is parsed, staged, and validated
before any academic record can be committed.
</p>
<h2 id="ledger-upload-title">Upload academic records</h2>
<p>Upload a CSV or Excel file. It's staged and validated before you commit it.</p>
</div>

<FileUploadField
accept=".csv,text/csv"
accept=".csv,text/csv,.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
aria-describedby={`ledger-file-help${
validationMessage || requestError ? ' ledger-file-error' : ''
}`}
aria-invalid={Boolean(validationMessage || requestError) || undefined}
aria-label="Official academic ledger CSV"
aria-label="Official academic ledger file"
className="visually-hidden"
disabled={isPending}
id={inputId}
Expand Down Expand Up @@ -110,12 +107,12 @@ export function LedgerUploadPanel({
<span aria-hidden="true" className="material-symbols-outlined ledger-dropzone-icon">
cloud_upload
</span>
<strong>Drag &amp; drop official transcript file here or click to browse</strong>
<span>Supported format: official academic CSV ledger file · Maximum size: 5 MiB</span>
<strong>Drag &amp; drop a file here, or click to browse</strong>
<span>CSV or Excel (.xlsx) · Max 5 MiB</span>
</button>

<p className="field-help" id="ledger-file-help">
Required headers and row values are validated by the backend before commit.
Columns and values are checked automatically before you can commit.
</p>

{file ? (
Expand All @@ -139,11 +136,11 @@ export function LedgerUploadPanel({

<div className="button-row ledger-upload-actions">
<Button disabled={!file} isLoading={isPending} onClick={() => file && onUpload(file)}>
Process and Stage Ledger
Upload
</Button>
{file ? (
<Button disabled={isPending} onClick={clearFile} variant="secondary">
Clear selected file
Clear
</Button>
) : null}
</div>
Expand Down
11 changes: 10 additions & 1 deletion src/features/academic-ledger/components/LedgerUploadsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -51,14 +55,19 @@ export function LedgerUploadsTable({
<StatusBadge tone={validation.tone}>{validation.label}</StatusBadge>
</td>
<td data-label="Rows">{item.totalRows}</td>
<td data-label="Action">
<td className="ledger-action-cell" data-label="Action">
<Button
aria-pressed={selectedId === item.uploadId}
onClick={() => onSelect(item.uploadId)}
variant="secondary"
>
Inspect
</Button>
{!DELETE_BLOCKED_STATUSES.has(item.uploadStatus) ? (
<Button onClick={() => onDelete(item)} variant="secondary">
Remove
</Button>
) : null}
</td>
</tr>
)
Expand Down
10 changes: 10 additions & 0 deletions src/features/academic-ledger/hooks/useLedgerUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
enabled: Boolean(uploadId),
queryKey: academicLedgerKeys.upload(uploadId ?? ''),
queryFn: ({ signal }) => academicLedgerApi.getUpload(uploadId ?? '', signal),
refetchInterval: (query) => ledgerPollInterval(query.state.data),

Check failure on line 30 in src/features/academic-ledger/hooks/useLedgerUpload.ts

View workflow job for this annotation

GitHub Actions / preview-build

Argument of type 'WithLedgerContentType<ApiAcademicLedgerUploadDetailResponse> | undefined' is not assignable to parameter of type 'ApiAcademicLedgerUploadDetailResponse | undefined'.
retry: shouldRetryAcademicLedger,
})
}
Expand All @@ -45,3 +45,13 @@
},
})
}

export function useDeleteLedgerUpload() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (uploadId: string) => academicLedgerApi.remove(uploadId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: academicLedgerKeys.uploads() })
},
})
}
80 changes: 55 additions & 25 deletions src/features/academic-ledger/pages/AcademicLedgerPage.tsx
Original file line number Diff line number Diff line change
@@ -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<ApiAcademicLedgerUploadSummaryResponse | null>(null)

useEffect(() => {
const previousTitle = document.title
Expand Down Expand Up @@ -69,15 +66,8 @@
}
/>

<LedgerAcademicInspection
onQueryChange={updateStudents}
onSearchChange={setStudentSearchInput}
query={state.students}
searchInput={studentSearchInput}
/>

{state.uploadId && selected.isPending ? <LedgerSelectedBatchSkeleton /> : null}
{selected.data ? <LedgerUploadStatus detail={selected.data} /> : null}

Check failure on line 70 in src/features/academic-ledger/pages/AcademicLedgerPage.tsx

View workflow job for this annotation

GitHub Actions / preview-build

Type 'WithLedgerContentType<ApiAcademicLedgerUploadDetailResponse>' is not assignable to type 'ApiAcademicLedgerUploadDetailResponse'.
{selected.isError ? (
<ErrorState
title="Unable to load selected batch"
Expand All @@ -94,7 +84,7 @@
uploadId={state.uploadId}
/>
) : null}
{selected.data && isReviewable ? <LedgerCommitControl detail={selected.data} /> : null}

Check failure on line 87 in src/features/academic-ledger/pages/AcademicLedgerPage.tsx

View workflow job for this annotation

GitHub Actions / preview-build

Type 'WithLedgerContentType<ApiAcademicLedgerUploadDetailResponse>' is not assignable to type 'ApiAcademicLedgerUploadDetailResponse'.

<section aria-labelledby="ledger-batches-title" className="section-card ledger-batches-panel">
<div className="ledger-section-heading">
Expand Down Expand Up @@ -146,7 +136,8 @@
) : null}
{uploads.data?.items.length ? (
<LedgerUploadsTable
items={uploads.data.items}

Check failure on line 139 in src/features/academic-ledger/pages/AcademicLedgerPage.tsx

View workflow job for this annotation

GitHub Actions / preview-build

Type 'WithLedgerContentType<ApiAcademicLedgerUploadSummaryResponse>[]' is not assignable to type 'ApiAcademicLedgerUploadSummaryResponse[]'.
onDelete={setDeleting}
selectedId={state.uploadId}
onSelect={selectUpload}
/>
Expand All @@ -170,6 +161,45 @@
/>
) : null}
</section>

{deleting ? (
<ConfirmDialog
closeDisabled={deleteUpload.isPending}
onClose={() => setDeleting(null)}
title="Remove upload"
>
<p>
Remove <strong>{deleting.originalFilename}</strong>? This cannot be undone.
</p>
{deleteUpload.isError ? (
<p className="error-text" role="alert">
{mapApiError(deleteUpload.error, 'protected').message}
</p>
) : null}
<div className="modal-actions">
<Button
disabled={deleteUpload.isPending}
onClick={() => setDeleting(null)}
variant="secondary"
>
Cancel
</Button>
<Button
isLoading={deleteUpload.isPending}
onClick={() => {
deleteUpload.mutate(deleting.uploadId, {
onSuccess: () => {
if (state.uploadId === deleting.uploadId) selectUpload(null)
setDeleting(null)
},
})
}}
>
Remove
</Button>
</div>
</ConfirmDialog>
) : null}
</main>
)
}
Loading
Loading