update exam and attendance
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
+44
-1
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user