update exam and attendance

This commit is contained in:
root
2026-04-26 14:57:41 -04:00
parent 0687d4677f
commit ea495f8382
4 changed files with 912 additions and 181 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+44 -1
View File
@@ -857,6 +857,33 @@ export async function fetchExamDraftAdminData(): Promise<ExamDraftAdminResponse>
return apiFetch<ExamDraftAdminResponse>('/api/v1/exams/drafts/admin')
}
export async function openProtectedApiFile(path: string): Promise<void> {
const headers = new Headers()
const token = getStoredToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(apiUrl(path), { headers })
if (!res.ok) {
const body: unknown = await res.json().catch(() => null)
const msg =
typeof body === 'object' && body !== null && 'message' in body
? String((body as { message?: unknown }).message ?? '')
: ''
throw new Error(msg || `HTTP ${res.status}`)
}
const blob = await res.blob()
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectUrl
link.target = '_blank'
link.rel = 'noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
}
async function fetchMultipartJson<T>(path: string, formData: FormData): Promise<T> {
const headers = new Headers({ Accept: 'application/json' })
const token = getStoredToken()
@@ -868,10 +895,26 @@ async function fetchMultipartJson<T>(path: string, formData: FormData): Promise<
})
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
const msg =
let msg =
typeof body === 'object' && body !== null && 'message' in body
? String((body as { message?: unknown }).message ?? '')
: ''
if (
msg === 'Validation failed.' &&
typeof body === 'object' &&
body !== null &&
'errors' in body
) {
const errors = (body as { errors?: Record<string, unknown> }).errors
if (errors && typeof errors === 'object') {
const firstField = Object.values(errors)[0]
if (Array.isArray(firstField) && firstField.length > 0) {
msg = String(firstField[0] ?? msg)
}
}
}
throw new Error(msg || `HTTP ${res.status}`)
}
return body as T
File diff suppressed because it is too large Load Diff
+24 -13
View File
@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { fetchAdministratorCalendarEvents } from '../../api/session'
import { fetchAdministratorCalendarEvents, openProtectedApiFile } from '../../api/session'
import type { ExamDraftRow, SchoolCalendarEventRow } from '../../api/types'
import { apiUrl } from '../../lib/apiOrigin'
import { TeacherShell } from './TeacherShell'
import { useTeacherResource } from './useTeacherResource'
import { postTeacherMultipart } from '../../api/teacherSession'
@@ -114,8 +113,8 @@ type FrontendMeResponse = {
const EXAM_DRAFT_MAX_BYTES = 12 * 1024 * 1024
const EXAM_DRAFT_ALLOWED_EXTENSIONS = ['doc', 'docx']
function teacherExamFileUrl(kind: 'teacher' | 'final', filename?: string | null) {
return filename ? apiUrl(`/api/v1/files/exams/${kind}/${encodeURIComponent(filename)}`) : null
function teacherExamFilePath(kind: 'teacher' | 'final', filename?: string | null) {
return filename ? `/api/v1/files/exams/${kind}/${encodeURIComponent(filename)}` : null
}
/** `teacher/competition_scores/index.php` */
@@ -883,8 +882,8 @@ export function TeacherExamDraftsPage() {
</tr>
) : (
(payload?.drafts ?? []).map((draft) => {
const teacherFile = teacherExamFileUrl('teacher', draft.teacher_file)
const finalFile = teacherExamFileUrl('final', draft.final_pdf_file ?? draft.final_file)
const teacherFile = teacherExamFilePath('teacher', draft.teacher_file)
const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file)
return (
<tr key={draft.id}>
<td>
@@ -900,16 +899,24 @@ export function TeacherExamDraftsPage() {
<td>
<div className="d-flex flex-column gap-1 small">
{teacherFile ? (
<a href={teacherFile} target="_blank" rel="noreferrer">
<button
type="button"
className="btn btn-link p-0 text-start"
onClick={() => void openProtectedApiFile(teacherFile)}
>
{draft.teacher_filename ?? 'Teacher file'}
</a>
</button>
) : (
<span className="text-muted">No uploaded draft</span>
)}
{finalFile ? (
<a href={finalFile} target="_blank" rel="noreferrer">
<button
type="button"
className="btn btn-link p-0 text-start"
onClick={() => void openProtectedApiFile(finalFile)}
>
{draft.final_filename ?? 'Reviewed final'}
</a>
</button>
) : null}
</div>
</td>
@@ -937,7 +944,7 @@ export function TeacherExamDraftsPage() {
) : (
<div className="row g-3">
{(payload?.legacy_exams ?? []).map((draft) => {
const finalFile = teacherExamFileUrl('final', draft.final_pdf_file ?? draft.final_file)
const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file)
return (
<div key={draft.id} className="col-12 col-xl-6">
<article className="teacher-calendar-agenda-item h-100">
@@ -951,9 +958,13 @@ export function TeacherExamDraftsPage() {
</p>
<p className="small text-muted mb-2">{draft.description ?? 'No description provided.'}</p>
{finalFile ? (
<a className="btn btn-sm btn-outline-primary" href={finalFile} target="_blank" rel="noreferrer">
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => void openProtectedApiFile(finalFile)}
>
Download exam
</a>
</button>
) : (
<span className="text-muted small">No file available.</span>
)}