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')
|
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> {
|
async function fetchMultipartJson<T>(path: string, formData: FormData): Promise<T> {
|
||||||
const headers = new Headers({ Accept: 'application/json' })
|
const headers = new Headers({ Accept: 'application/json' })
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
@@ -868,10 +895,26 @@ async function fetchMultipartJson<T>(path: string, formData: FormData): Promise<
|
|||||||
})
|
})
|
||||||
const body: unknown = await res.json().catch(() => null)
|
const body: unknown = await res.json().catch(() => null)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg =
|
let msg =
|
||||||
typeof body === 'object' && body !== null && 'message' in body
|
typeof body === 'object' && body !== null && 'message' in body
|
||||||
? String((body as { message?: unknown }).message ?? '')
|
? 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}`)
|
throw new Error(msg || `HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
return body as T
|
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 { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
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 type { ExamDraftRow, SchoolCalendarEventRow } from '../../api/types'
|
||||||
import { apiUrl } from '../../lib/apiOrigin'
|
|
||||||
import { TeacherShell } from './TeacherShell'
|
import { TeacherShell } from './TeacherShell'
|
||||||
import { useTeacherResource } from './useTeacherResource'
|
import { useTeacherResource } from './useTeacherResource'
|
||||||
import { postTeacherMultipart } from '../../api/teacherSession'
|
import { postTeacherMultipart } from '../../api/teacherSession'
|
||||||
@@ -114,8 +113,8 @@ type FrontendMeResponse = {
|
|||||||
const EXAM_DRAFT_MAX_BYTES = 12 * 1024 * 1024
|
const EXAM_DRAFT_MAX_BYTES = 12 * 1024 * 1024
|
||||||
const EXAM_DRAFT_ALLOWED_EXTENSIONS = ['doc', 'docx']
|
const EXAM_DRAFT_ALLOWED_EXTENSIONS = ['doc', 'docx']
|
||||||
|
|
||||||
function teacherExamFileUrl(kind: 'teacher' | 'final', filename?: string | null) {
|
function teacherExamFilePath(kind: 'teacher' | 'final', filename?: string | null) {
|
||||||
return filename ? apiUrl(`/api/v1/files/exams/${kind}/${encodeURIComponent(filename)}`) : null
|
return filename ? `/api/v1/files/exams/${kind}/${encodeURIComponent(filename)}` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `teacher/competition_scores/index.php` */
|
/** `teacher/competition_scores/index.php` */
|
||||||
@@ -883,8 +882,8 @@ export function TeacherExamDraftsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
(payload?.drafts ?? []).map((draft) => {
|
(payload?.drafts ?? []).map((draft) => {
|
||||||
const teacherFile = teacherExamFileUrl('teacher', draft.teacher_file)
|
const teacherFile = teacherExamFilePath('teacher', draft.teacher_file)
|
||||||
const finalFile = teacherExamFileUrl('final', draft.final_pdf_file ?? draft.final_file)
|
const finalFile = teacherExamFilePath('final', draft.final_pdf_file ?? draft.final_file)
|
||||||
return (
|
return (
|
||||||
<tr key={draft.id}>
|
<tr key={draft.id}>
|
||||||
<td>
|
<td>
|
||||||
@@ -900,16 +899,24 @@ export function TeacherExamDraftsPage() {
|
|||||||
<td>
|
<td>
|
||||||
<div className="d-flex flex-column gap-1 small">
|
<div className="d-flex flex-column gap-1 small">
|
||||||
{teacherFile ? (
|
{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'}
|
{draft.teacher_filename ?? 'Teacher file'}
|
||||||
</a>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted">No uploaded draft</span>
|
<span className="text-muted">No uploaded draft</span>
|
||||||
)}
|
)}
|
||||||
{finalFile ? (
|
{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'}
|
{draft.final_filename ?? 'Reviewed final'}
|
||||||
</a>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -937,7 +944,7 @@ export function TeacherExamDraftsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
{(payload?.legacy_exams ?? []).map((draft) => {
|
{(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 (
|
return (
|
||||||
<div key={draft.id} className="col-12 col-xl-6">
|
<div key={draft.id} className="col-12 col-xl-6">
|
||||||
<article className="teacher-calendar-agenda-item h-100">
|
<article className="teacher-calendar-agenda-item h-100">
|
||||||
@@ -951,9 +958,13 @@ export function TeacherExamDraftsPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="small text-muted mb-2">{draft.description ?? 'No description provided.'}</p>
|
<p className="small text-muted mb-2">{draft.description ?? 'No description provided.'}</p>
|
||||||
{finalFile ? (
|
{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
|
Download exam
|
||||||
</a>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted small">No file available.</span>
|
<span className="text-muted small">No file available.</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user