feat: education
Deploy banu-front / deploy (push) Failing after 6s

This commit is contained in:
sajjadtalkhabi
2026-06-11 21:09:12 +03:30
parent 020a47f735
commit 4011c2f83b
18 changed files with 558 additions and 35 deletions
+102 -5
View File
@@ -4,10 +4,11 @@
// options: [{ id, optionText, isCorrect }] }] }
// (Backend hides `is_correct` from non-admin reads; the admin mock shows it.)
const buildQuestion = (id, questionText, position, options, correctIndex) => ({
const buildQuestion = (id, questionText, position, options, correctIndex, score = 10) => ({
id,
questionText,
position,
score,
options: options.map((optionText, idx) => ({
id: id * 100 + idx + 1,
optionText,
@@ -49,7 +50,10 @@ export const adminExams = [
sessionId: 101,
title: 'آزمون پایان فصل اول اخلاق',
description: 'آزمون چهار گزینه‌ای از مفاهیم درس‌های ۱ تا ۳.',
passScore: 12,
score: 20,
minimumScore: 12,
durationMinutes: 30,
isRandom: false,
isActive: true,
questions: ethicsQuestions,
// ── UI-only fields for ExamItem / ExamDetailsModal (not in backend). ──
@@ -65,7 +69,10 @@ export const adminExams = [
sessionId: 102,
title: 'آزمون مفاهیم قرآنی - میان‌ترم',
description: 'آزمون میان‌ترم برای مرور آیات کلیدی.',
passScore: 14,
score: 20,
minimumScore: 14,
durationMinutes: 20,
isRandom: false,
isActive: true,
questions: quranQuestions,
course: { id: 2, title: 'مفاهیم قرآنی' },
@@ -86,8 +93,10 @@ export const examAttempts = new Map([
{
id: 14,
examId: 201,
score: 85,
score: 20,
isPassed: true,
correctCount: 2,
totalQuestions: 2,
startedAt: '2026-04-12T15:00:00+03:30',
submittedAt: '2026-04-12T15:30:00+03:30',
deadlineAt: '2026-04-12T15:30:00+03:30',
@@ -109,8 +118,10 @@ export const examAttempts = new Map([
{
id: 15,
examId: 201,
score: 42,
score: 10,
isPassed: false,
correctCount: 1,
totalQuestions: 2,
startedAt: '2026-04-13T09:00:00+03:30',
submittedAt: '2026-04-13T09:25:00+03:30',
deadlineAt: '2026-04-13T09:30:00+03:30',
@@ -133,6 +144,92 @@ export const examAttempts = new Map([
],
])
// Mirrors GET /exam-attempts/:id — full per-attempt review. Questions carry
// every option with isCorrect (answer key) + isSelected (what the user picked).
const reviewQuestions = (questions, selectedByQuestionId) =>
questions.map((q) => ({
id: q.id,
questionText: q.questionText,
position: q.position,
score: q.score,
selectedOptionId: selectedByQuestionId[q.id] ?? null,
options: q.options.map((o) => ({
id: o.id,
optionText: o.optionText,
isCorrect: o.isCorrect,
isSelected: selectedByQuestionId[q.id] === o.id,
})),
}))
const examContext = {
id: 201,
title: 'آزمون پایان فصل اول اخلاق',
description: null,
score: 20,
minimumScore: 12,
durationMinutes: 30,
session: {
id: 101,
title: 'مقدمه‌ای بر اخلاق اسلامی',
course: {
id: 1,
title: 'اصول اخلاق اسلامی',
term: { id: 1, title: 'ترم بهار ۱۴۰۵', score: 20, minimumScore: 12 },
},
},
}
export const examAttemptReviews = new Map([
[
14,
{
id: 14,
examId: 201,
userId: 100,
user: {
id: 100,
name: 'فاطمه رضایی',
phone: '+989121234567',
roles: ['student'],
avatarUrl: null,
},
score: 20,
isPassed: true,
correctCount: 2,
totalQuestions: 2,
startedAt: '2026-04-12T15:00:00+03:30',
submittedAt: '2026-04-12T15:30:00+03:30',
exam: examContext,
// both answers correct (q1 → option 102, q2 → option 201)
questions: reviewQuestions(ethicsQuestions, { 1: 102, 2: 201 }),
},
],
[
15,
{
id: 15,
examId: 201,
userId: 102,
user: {
id: 102,
name: 'مریم احمدی',
phone: '+989121111111',
roles: ['student'],
avatarUrl: null,
},
score: 10,
isPassed: false,
correctCount: 1,
totalQuestions: 2,
startedAt: '2026-04-13T09:00:00+03:30',
submittedAt: '2026-04-13T09:25:00+03:30',
exam: examContext,
// q1 correct (102), q2 wrong (203)
questions: reviewQuestions(ethicsQuestions, { 1: 102, 2: 203 }),
},
],
])
// Kept for the FE-mock-only single-participant detail endpoint (no backend yet).
export const examParticipants = new Map([
[
@@ -0,0 +1,40 @@
// Mirrors the backend Homework shape (camelized). is_priority=true means the
// homework is required to pass the term; effective_deadline falls back to the
// term end date when no explicit deadline is set.
const sessionContext = (id, title, courseTitle, termTitle) => ({
id,
title,
course: { id: id - 90, title: courseTitle, term: { id: 1, title: termTitle } },
})
export const studentHomeworks = [
{
id: 301,
sessionId: 101,
title: 'تکلیف جلسه اول اخلاق',
description:
'یک متن ۵۰۰ کلمه‌ای دربارهٔ مفهوم تقوا بنویسید و فایل صوتی توضیح آن را بارگذاری کنید.',
deadline: '2026-05-01T23:59:00+03:30',
effectiveDeadline: '2026-05-01T23:59:00+03:30',
isActive: true,
isPriority: true,
submittersCount: 8,
createdAt: '2026-04-01T10:00:00.000Z',
media: [],
session: sessionContext(101, 'مقدمه‌ای بر اخلاق اسلامی', 'اصول اخلاق اسلامی', 'ترم پاییز ۱۴۰۴'),
},
{
id: 302,
sessionId: 103,
title: 'تکلیف احکام نماز جماعت',
description: 'خلاصه‌ای از احکام نماز جماعت را به همراه یک نمونهٔ صوتی ارائه دهید.',
deadline: null,
effectiveDeadline: '2026-04-20T00:00:00.000Z',
isActive: true,
isPriority: false,
submittersCount: 3,
createdAt: '2026-04-05T10:00:00.000Z',
media: [],
session: sessionContext(103, 'احکام نماز جماعت', 'فقه عبادات', 'ترم زمستان ۱۴۰۴'),
},
]
+21 -4
View File
@@ -2,7 +2,12 @@ import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { adminCourses } from '@/services/mock/fixtures/admin-courses'
import { adminSessions } from '@/services/mock/fixtures/admin-sessions'
import { adminExams, examAttempts, examParticipants } from '@/services/mock/fixtures/admin-exams'
import {
adminExams,
examAttempts,
examAttemptReviews,
examParticipants,
} from '@/services/mock/fixtures/admin-exams'
import {
filterDateRange,
filterItems,
@@ -59,7 +64,10 @@ register('POST', endpoints.addNewExam, ({ data }) => {
sessionId: session?.id ?? Number(data.sessionId) ?? null,
title: data.title || '',
description: data.description || '',
passScore: Number(data.passingScore ?? data.passScore) || 0,
score: Number(data.score) || 0,
minimumScore: Number(data.minimumScore ?? data.passingScore ?? data.passScore) || 0,
durationMinutes: Number(data.durationMinutes) || 0,
isRandom: data.isRandom ?? false,
isActive: data.isActive ?? true,
questions: [],
// UI-only fallbacks for ExamItem / ExamDetailsModal.
@@ -82,8 +90,10 @@ register('PATCH', endpoints.updateExam, ({ params, data }) => {
const patch = {}
if (data.title !== undefined) patch.title = data.title
if (data.description !== undefined) patch.description = data.description
if (data.passingScore !== undefined) patch.passScore = Number(data.passingScore) || 0
if (data.passScore !== undefined) patch.passScore = Number(data.passScore) || 0
if (data.score !== undefined) patch.score = Number(data.score) || 0
if (data.minimumScore !== undefined) patch.minimumScore = Number(data.minimumScore) || 0
if (data.durationMinutes !== undefined) patch.durationMinutes = Number(data.durationMinutes) || 0
if (data.isRandom !== undefined) patch.isRandom = !!data.isRandom
if (data.isActive !== undefined) patch.isActive = !!data.isActive
if (data.sessionId !== undefined) {
const session = adminSessions.find((s) => s.id === Number(data.sessionId))
@@ -115,6 +125,7 @@ register('POST', endpoints.addExamQuestion, ({ params, data }) => {
id: questionId,
questionText: data.questionText || '',
position: data.position ?? (exam.questions?.length ?? 0) + 1,
score: Number(data.score) || 0,
options: options.map((opt, idx) => ({
id: questionId * 100 + idx + 1,
optionText: opt.optionText || '',
@@ -157,6 +168,12 @@ register('GET', endpoints.getExamAttempts, ({ params, query }) => {
return { success: true, message: 'OK', data: { items, meta } }
})
// GET /exam-attempts/:id — full per-attempt review payload.
register('GET', endpoints.showExamAttempt, ({ params }) => {
const review = examAttemptReviews.get(Number(params.id))
return { success: true, message: 'OK', data: review || null }
})
// FE-mock-only — single-participant detail not yet on the backend.
register('GET', endpoints.showExamParticipant, ({ params }) => {
const list = examParticipants.get(Number(params.examId)) || []
+99
View File
@@ -0,0 +1,99 @@
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { adminExams } from '@/services/mock/fixtures/admin-exams'
import { adminCourses } from '@/services/mock/fixtures/admin-courses'
import { filterItems, isoNow, makeId, paginate } from '@/services/mock/helpers'
// In-memory attempt history for the mock student. Submissions append here and
// surface on the exam page via GET /student/exam-results.
const studentResults = [
{
id: 9001,
examId: 201,
score: 20,
isPassed: true,
correctCount: 2,
totalQuestions: 2,
startedAt: '2026-04-12T15:00:00+03:30',
submittedAt: '2026-04-12T15:30:00+03:30',
deadlineAt: '2026-04-12T15:30:00+03:30',
},
]
// examId → un-submitted attempt, so POST /start is idempotent.
const openAttempts = new Map()
const termIdOfExam = (exam) => {
const course = adminCourses.find((c) => c.id === Number(exam.course?.id))
return course?.termId ?? course?.term?.id ?? null
}
register('GET', endpoints.getStudentExamsList, ({ query }) => {
const list = filterItems(adminExams, query, {
sessionId: (item, v) => String(item.sessionId) === String(v),
courseId: (item, v) => String(item.course?.id) === String(v),
termId: (item, v) => String(termIdOfExam(item)) === String(v),
})
const { data: items, meta } = paginate(list, query)
return { success: true, message: 'OK', data: { items, meta } }
})
register('GET', endpoints.getStudentExamResults, ({ query }) => {
const { data: items, meta } = paginate([...studentResults].reverse(), query)
return { success: true, message: 'OK', data: { items, meta } }
})
register('POST', endpoints.startExam, ({ params }) => {
const examId = Number(params.examId)
const existing = openAttempts.get(examId)
if (existing) return { success: true, message: 'Exam started.', data: existing }
const exam = adminExams.find((e) => String(e.id) === String(examId))
const duration = exam?.durationMinutes || 30
const attempt = {
id: makeId(),
examId,
score: 0,
isPassed: false,
correctCount: 0,
totalQuestions: (exam?.questions || []).length,
startedAt: isoNow(),
submittedAt: null,
deadlineAt: new Date(Date.now() + duration * 60_000).toISOString(),
}
openAttempts.set(examId, attempt)
return { success: true, message: 'Exam started.', data: attempt }
})
register('POST', endpoints.submitExam, ({ params, data }) => {
const examId = Number(params.examId)
const exam = adminExams.find((e) => String(e.id) === String(examId))
const questions = exam?.questions || []
const answers = Array.isArray(data.answers) ? data.answers : []
let score = 0
let correctCount = 0
for (const q of questions) {
const correct = (q.options || []).find((o) => o.isCorrect)
const ans = answers.find((a) => String(a.questionId) === String(q.id))
if (ans && correct && String(ans.selectedOptionId) === String(correct.id)) {
score += q.score || 0
correctCount += 1
}
}
const open = openAttempts.get(examId)
const attempt = {
id: open?.id ?? makeId(),
examId,
score,
isPassed: score >= (exam?.minimumScore ?? 0),
correctCount,
totalQuestions: questions.length,
startedAt: open?.startedAt ?? isoNow(),
submittedAt: isoNow(),
deadlineAt: open?.deadlineAt ?? isoNow(),
}
studentResults.push(attempt)
openAttempts.delete(examId)
return { success: true, message: 'Exam submitted.', data: attempt }
})
@@ -0,0 +1,43 @@
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { studentHomeworks } from '@/services/mock/fixtures/student-homeworks'
import { filterItems, findOrThrow, isoNow, makeId, paginate } from '@/services/mock/helpers'
register('GET', endpoints.getStudentHomeworks, ({ query }) => {
const list = filterItems(studentHomeworks, query, {
sessionId: (item, v) => String(item.sessionId) === String(v),
courseId: (item, v) => String(item.session?.course?.id) === String(v),
termId: (item, v) => String(item.session?.course?.term?.id) === String(v),
})
const { data: items, meta } = paginate(list, query)
return { success: true, message: 'OK', data: { items, meta } }
})
register('GET', endpoints.showHomework, ({ params }) => ({
success: true,
message: 'OK',
data: findOrThrow(studentHomeworks, params.id),
}))
register('POST', endpoints.submitHomework, ({ params, data }) => ({
success: true,
message: 'Homework submitted.',
data: {
id: makeId(),
homeworkId: Number(params.homeworkId),
userId: 5,
status: 'pending',
teacherFeedback: null,
reviewedAt: null,
media: [
{
id: Number(data.mediaId) || makeId(),
collectionName: 'submission',
fileName: 'submission',
url: null,
downloadUrl: null,
},
],
submittedAt: isoNow(),
},
}))
+29
View File
@@ -0,0 +1,29 @@
import { paginate } from '@/services/mock/helpers'
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import { adminTerms } from '@/services/mock/fixtures/admin-terms'
const STUDENT_ID = 5
// TermEnrollment rows for the mock student, each embedding its term. Two active
// (in-progress) terms and the rest completed, mirroring GET /student/my-terms.
const enrollments = adminTerms.map((term, i) => ({
id: 10 + i,
userId: STUDENT_ID,
termId: term.id,
status: i < 2 ? 'active' : 'completed',
completedAt: i < 2 ? null : '2026-01-20T00:00:00.000Z',
term,
}))
register('GET', endpoints.getStudentTerms, ({ query }) => {
let list = enrollments
if (query.status) list = list.filter((e) => e.status === String(query.status))
const { data: items, meta } = paginate(list, query)
return { success: true, message: 'OK', data: { items, meta } }
})
register('GET', endpoints.showStudentTerm, ({ params }) => {
const found = enrollments.find((e) => String(e.termId) === String(params.id))
return { success: true, message: 'OK', data: found || null }
})