@tinyweb_dev/oe-exam-sdk 0.1.30 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/components/exams/ExamPreviewDialog.d.ts +9 -3
  2. package/dist/components/exams/ExamPreviewDialog.js +23 -5
  3. package/dist/components/exams/ExamPreviewPartContent.d.ts +2 -1
  4. package/dist/components/exams/ExamPreviewPartContent.js +3 -2
  5. package/dist/components/exams/ExamPreviewSidebar.d.ts +3 -1
  6. package/dist/components/exams/ExamPreviewSidebar.js +7 -2
  7. package/dist/components/exams/exam-preview.mode.d.ts +4 -0
  8. package/dist/components/exams/exam-preview.mode.js +5 -0
  9. package/dist/components/exams/index.d.ts +1 -0
  10. package/dist/components/exams/index.js +1 -0
  11. package/dist/components/exams/take/components/QuestionRenderer.js +5 -1
  12. package/dist/components/exams/take/utils/answer-transformers.js +2 -1
  13. package/dist/components/exams/take/utils/question-transformers.d.ts +6 -1
  14. package/dist/components/exams/take/utils/question-transformers.js +33 -0
  15. package/dist/components/questions/_shared/config/question-types.config.js +6 -0
  16. package/dist/components/questions/_shared/types/answer-the-question-group.type.d.ts +32 -0
  17. package/dist/components/questions/_shared/types/answer-the-question-group.type.js +1 -0
  18. package/dist/components/questions/creator/question-type-registry.js +2 -0
  19. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.d.ts +2 -0
  20. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +13 -0
  21. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.d.ts +2 -0
  22. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js +183 -0
  23. package/dist/components/questions/types/answer-the-question-group/index.d.ts +5 -0
  24. package/dist/components/questions/types/answer-the-question-group/index.js +5 -0
  25. package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.d.ts +4 -0
  26. package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +83 -0
  27. package/dist/components/questions/types/answer-the-question-group/register.d.ts +2 -0
  28. package/dist/components/questions/types/answer-the-question-group/register.js +36 -0
  29. package/dist/components/questions/types/answer-the-question-group/transform.d.ts +2 -0
  30. package/dist/components/questions/types/answer-the-question-group/transform.js +72 -0
  31. package/dist/components/questions/types/speaking-conversation/SpeakingConversationPreviewClient.js +1 -1
  32. package/dist/components/questions/viewer/QuestionViewer.js +2 -2
  33. package/dist/components/questions/viewer/SpontaneousQaGroupViewer.js +4 -4
  34. package/dist/components/results/ReviewQuestionRenderer.js +1 -0
  35. package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.d.ts +19 -0
  36. package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.js +27 -0
  37. package/dist/components/results/renderers/review-question-body-dedicated.d.ts +1 -0
  38. package/dist/components/results/renderers/review-question-body-dedicated.js +47 -2
  39. package/dist/components/results/review-data.js +12 -0
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.js +1 -1
  42. package/dist/shared/constants/question-skills.js +2 -0
  43. package/dist/shared/lib/i18n/messages/en/admin.d.ts +5 -0
  44. package/dist/shared/lib/i18n/messages/en/admin.js +5 -0
  45. package/dist/shared/lib/i18n/messages/en.d.ts +5 -0
  46. package/dist/shared/lib/i18n/messages/vi/admin.d.ts +5 -0
  47. package/dist/shared/lib/i18n/messages/vi/admin.js +5 -0
  48. package/dist/shared/lib/i18n/messages/vi.d.ts +5 -0
  49. package/dist/shared/lib/utils/question-reverse-transform.js +39 -0
  50. package/dist/shared/lib/utils/question-transform.js +2 -0
  51. package/dist/shared/types/common.types.d.ts +1 -1
  52. package/dist/shared/types/questions/answer-the-question-group.d.ts +39 -0
  53. package/dist/shared/types/questions/answer-the-question-group.js +1 -0
  54. package/dist/shared/types/questions/index.d.ts +1 -0
  55. package/dist/shared/types/questions/index.js +2 -0
  56. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import type { ExamQuestionsSdkApi } from '../../api/exam-questions.types';
2
+ import { ExamPreviewMode } from './exam-preview.mode';
2
3
  export interface ExamPreviewDialogProps {
3
4
  open: boolean;
4
5
  onOpenChange: (open: boolean) => void;
@@ -8,12 +9,17 @@ export interface ExamPreviewDialogProps {
8
9
  api: ExamQuestionsSdkApi;
9
10
  /** Fallback heading shown until the exam name loads. */
10
11
  title?: string;
12
+ /** Initial preview mode. Default is student view without answers. */
13
+ defaultMode?: ExamPreviewMode;
11
14
  }
12
15
  /**
13
16
  * Read-only popup that previews an exam by id.
14
17
  *
18
+ * Supports two modes:
19
+ * - 'student' (default): interactive student taking view without revealing answers
20
+ * - 'answers': review view with correct answers highlighted and explanations
21
+ *
15
22
  * UI mirrors the host app: one Part per screen (all questions of that part),
16
- * Next/Prev navigates between parts. No attempt is created — data comes from
17
- * {@link ExamQuestionsSdkApi}. Questions render via QuestionViewer in review mode.
23
+ * Next/Prev navigates between parts. Data comes from {@link ExamQuestionsSdkApi}.
18
24
  */
19
- export declare function ExamPreviewDialog({ open, onOpenChange, examId, api, title, }: ExamPreviewDialogProps): import("react").JSX.Element;
25
+ export declare function ExamPreviewDialog({ open, onOpenChange, examId, api, title, defaultMode, }: ExamPreviewDialogProps): import("react").JSX.Element;
@@ -1,30 +1,42 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useMemo, useRef, useState } from 'react';
4
- import { Eye } from 'lucide-react';
4
+ import { CheckCircle2, Eye, GraduationCap } from 'lucide-react';
5
5
  import { Button } from '../../components/ui/button';
6
6
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../components/ui/dialog';
7
7
  import { useT } from '../../shared/lib/i18n';
8
+ import { cn } from '../../shared/lib/utils';
9
+ import { ExamPreviewMode } from './exam-preview.mode';
8
10
  import { ExamPreviewPartContent } from './ExamPreviewPartContent';
9
11
  import { ExamPreviewSidebar } from './ExamPreviewSidebar';
10
12
  import { getQuestionNumber, getQuestionPoints, groupQuestionsByPart, sumQuestionPoints, unwrapApiData, } from './exam-preview.types';
11
13
  /**
12
14
  * Read-only popup that previews an exam by id.
13
15
  *
16
+ * Supports two modes:
17
+ * - 'student' (default): interactive student taking view without revealing answers
18
+ * - 'answers': review view with correct answers highlighted and explanations
19
+ *
14
20
  * UI mirrors the host app: one Part per screen (all questions of that part),
15
- * Next/Prev navigates between parts. No attempt is created — data comes from
16
- * {@link ExamQuestionsSdkApi}. Questions render via QuestionViewer in review mode.
21
+ * Next/Prev navigates between parts. Data comes from {@link ExamQuestionsSdkApi}.
17
22
  */
18
- export function ExamPreviewDialog({ open, onOpenChange, examId, api, title, }) {
23
+ export function ExamPreviewDialog({ open, onOpenChange, examId, api, title, defaultMode = ExamPreviewMode.STUDENT, }) {
19
24
  const t = useT();
20
25
  const [exam, setExam] = useState(null);
21
26
  const [questions, setQuestions] = useState([]);
22
27
  const [currentPartIndex, setCurrentPartIndex] = useState(0);
23
28
  const [showPartsDetail, setShowPartsDetail] = useState(false);
29
+ const [previewMode, setPreviewMode] = useState(defaultMode);
24
30
  const [loading, setLoading] = useState(false);
25
31
  const [error, setError] = useState(null);
26
32
  const [reloadKey, setReloadKey] = useState(0);
27
33
  const contentRef = useRef(null);
34
+ // Sync / reset previewMode whenever dialog opens
35
+ useEffect(() => {
36
+ if (open) {
37
+ setPreviewMode(defaultMode);
38
+ }
39
+ }, [open, defaultMode]);
28
40
  // Fetch exam + questions whenever the dialog opens for an exam.
29
41
  useEffect(() => {
30
42
  if (!open || !examId) {
@@ -117,5 +129,11 @@ export function ExamPreviewDialog({ open, onOpenChange, examId, api, title, }) {
117
129
  scrollToTop();
118
130
  }
119
131
  };
120
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-h-[95vh] max-w-7xl overflow-y-auto", children: [_jsx(DialogHeader, { children: _jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(Eye, { className: "h-5 w-5 text-blue-600" }), t('admin.exams.preview.title', { name: heading })] }) }), _jsxs("div", { className: "flex h-[80vh] flex-col gap-4 sm:flex-row", children: [_jsx(ExamPreviewSidebar, { examName: heading, duration: duration, totalPoints: totalPoints, questions: questions, questionsByPart: questionsByPart, currentPart: currentPart, currentPartIndex: currentPartIndex, totalParts: totalParts, progress: progress, loading: loading, showPartsDetail: showPartsDetail, onTogglePartsDetail: () => setShowPartsDetail((value) => !value), onGoToPart: goToPart }), _jsx(ExamPreviewPartContent, { loading: loading, error: error, questions: questions, currentPart: currentPart, currentPartQuestions: currentPartQuestions, currentPartIndex: currentPartIndex, totalParts: totalParts, partDisplayName: partDisplayName, partTotalPoints: partTotalPoints, contentRef: contentRef, onRetry: () => setReloadKey((key) => key + 1), onPreviousPart: goToPreviousPart, onNextPart: goToNextPart })] }), _jsx("div", { className: "border-t pt-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("div", { className: "text-sm text-gray-600", children: t('admin.exams.preview.footerNote') }), _jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), className: "border-gray-300", children: t('admin.exams.preview.close') })] }) })] }) }));
132
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-h-[95vh] max-w-7xl overflow-y-auto", children: [_jsxs(DialogHeader, { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border-b pb-3 pr-8", children: [_jsxs(DialogTitle, { className: "flex items-center gap-2 text-lg font-bold", children: [_jsx(Eye, { className: "h-5 w-5 text-blue-600" }), t('admin.exams.preview.title', { name: heading })] }), _jsxs("div", { className: "inline-flex items-center rounded-lg border border-gray-200 bg-gray-100 p-1 shadow-inner", children: [_jsxs("button", { type: "button", onClick: () => setPreviewMode(ExamPreviewMode.STUDENT), className: cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-all', previewMode === ExamPreviewMode.STUDENT
133
+ ? 'bg-white text-blue-700 shadow-sm ring-1 ring-black/5'
134
+ : 'text-gray-600 hover:bg-gray-200/70 hover:text-gray-900'), children: [_jsx(GraduationCap, { className: "h-4 w-4" }), _jsx("span", { children: t('admin.exams.preview.mode.student') })] }), _jsxs("button", { type: "button", onClick: () => setPreviewMode(ExamPreviewMode.ANSWERS), className: cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-all', previewMode === ExamPreviewMode.ANSWERS
135
+ ? 'bg-emerald-600 text-white shadow-sm'
136
+ : 'text-gray-600 hover:bg-gray-200/70 hover:text-gray-900'), children: [_jsx(CheckCircle2, { className: "h-4 w-4" }), _jsx("span", { children: t('admin.exams.preview.mode.answers') })] })] })] }), _jsxs("div", { className: "flex h-[80vh] flex-col gap-4 sm:flex-row", children: [_jsx(ExamPreviewSidebar, { examName: heading, duration: duration, totalPoints: totalPoints, questions: questions, questionsByPart: questionsByPart, currentPart: currentPart, currentPartIndex: currentPartIndex, totalParts: totalParts, progress: progress, loading: loading, showPartsDetail: showPartsDetail, previewMode: previewMode, onTogglePartsDetail: () => setShowPartsDetail((value) => !value), onGoToPart: goToPart }), _jsx(ExamPreviewPartContent, { loading: loading, error: error, questions: questions, currentPart: currentPart, currentPartQuestions: currentPartQuestions, currentPartIndex: currentPartIndex, totalParts: totalParts, partDisplayName: partDisplayName, partTotalPoints: partTotalPoints, contentRef: contentRef, isReviewMode: previewMode === ExamPreviewMode.ANSWERS, onRetry: () => setReloadKey((key) => key + 1), onPreviousPart: goToPreviousPart, onNextPart: goToNextPart })] }), _jsx("div", { className: "border-t pt-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("div", { className: "text-sm text-gray-600", children: previewMode === ExamPreviewMode.ANSWERS
137
+ ? t('admin.exams.preview.footerNoteAnswers')
138
+ : t('admin.exams.preview.footerNoteStudent') }), _jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), className: "border-gray-300", children: t('admin.exams.preview.close') })] }) })] }) }));
121
139
  }
@@ -11,8 +11,9 @@ export interface ExamPreviewPartContentProps {
11
11
  partDisplayName: string;
12
12
  partTotalPoints: number;
13
13
  contentRef: RefObject<HTMLDivElement | null>;
14
+ isReviewMode?: boolean;
14
15
  onRetry: () => void;
15
16
  onPreviousPart: () => void;
16
17
  onNextPart: () => void;
17
18
  }
18
- export declare function ExamPreviewPartContent({ loading, error, questions, currentPart, currentPartQuestions, currentPartIndex, totalParts, partDisplayName, partTotalPoints, contentRef, onRetry, onPreviousPart, onNextPart, }: ExamPreviewPartContentProps): import("react").JSX.Element;
19
+ export declare function ExamPreviewPartContent({ loading, error, questions, currentPart, currentPartQuestions, currentPartIndex, totalParts, partDisplayName, partTotalPoints, contentRef, isReviewMode, onRetry, onPreviousPart, onNextPart, }: ExamPreviewPartContentProps): import("react").JSX.Element;
@@ -5,8 +5,9 @@ import { Badge } from '../../components/ui/badge';
5
5
  import { Button } from '../../components/ui/button';
6
6
  import { QuestionViewer } from '../questions/viewer/QuestionViewer';
7
7
  import { useT } from '../../shared/lib/i18n';
8
+ import { ExamPreviewMode } from './exam-preview.mode';
8
9
  import { difficultyBadgeClass, getQuestionDifficulty, getQuestionNumber, getQuestionPoints, } from './exam-preview.types';
9
- export function ExamPreviewPartContent({ loading, error, questions, currentPart, currentPartQuestions, currentPartIndex, totalParts, partDisplayName, partTotalPoints, contentRef, onRetry, onPreviousPart, onNextPart, }) {
10
+ export function ExamPreviewPartContent({ loading, error, questions, currentPart, currentPartQuestions, currentPartIndex, totalParts, partDisplayName, partTotalPoints, contentRef, isReviewMode = false, onRetry, onPreviousPart, onNextPart, }) {
10
11
  const t = useT();
11
12
  return (_jsx("div", { className: "flex-1 overflow-y-auto", children: _jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center justify-between border-b pb-4", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-semibold", children: loading ? (_jsx("span", { className: "text-gray-400", children: t('admin.exams.preview.loading') })) : totalParts > 0 ? (t('admin.exams.preview.partIndex', {
12
13
  index: String(currentPartIndex + 1),
@@ -22,6 +23,6 @@ export function ExamPreviewPartContent({ loading, error, questions, currentPart,
22
23
  number: String(qNumber),
23
24
  }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: "outline", children: t('admin.exams.preview.points', {
24
25
  count: String(getQuestionPoints(question)),
25
- }) }), _jsx(Badge, { className: difficultyBadgeClass(difficulty), children: difficulty })] })] }), _jsx(QuestionViewer, { question: question, isReviewMode: true })] }, question.id));
26
+ }) }), _jsx(Badge, { className: difficultyBadgeClass(difficulty), children: difficulty })] })] }), _jsx(QuestionViewer, { question: question, isReviewMode: isReviewMode }, `${question.id}-${isReviewMode ? ExamPreviewMode.ANSWERS : ExamPreviewMode.STUDENT}`)] }, question.id));
26
27
  }) })) : null }), !loading && totalParts > 0 && (_jsxs("div", { className: "mt-auto flex items-center justify-between border-t border-gray-200 bg-gray-50 px-4 py-3", children: [_jsxs(Button, { variant: "outline", onClick: onPreviousPart, disabled: currentPartIndex === 0, className: "flex h-9 items-center gap-2 px-3 text-sm", children: [_jsx(ArrowLeft, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('admin.exams.preview.prevPart') }), _jsx("span", { className: "sm:hidden", children: t('admin.exams.preview.prev') })] }), _jsxs("div", { className: "text-sm text-gray-600", children: [currentPartIndex + 1, " / ", totalParts] }), _jsxs(Button, { variant: "outline", onClick: onNextPart, disabled: currentPartIndex === totalParts - 1, className: "flex h-9 items-center gap-2 px-3 text-sm", children: [_jsx("span", { className: "hidden sm:inline", children: t('admin.exams.preview.nextPart') }), _jsx("span", { className: "sm:hidden", children: t('admin.exams.preview.next') }), _jsx(ArrowRight, { className: "h-4 w-4" })] })] }))] }) }));
27
28
  }
@@ -1,3 +1,4 @@
1
+ import { ExamPreviewMode } from './exam-preview.mode';
1
2
  import { type PreviewQuestion, type QuestionsByPart } from './exam-preview.types';
2
3
  export interface ExamPreviewSidebarProps {
3
4
  examName: string;
@@ -11,7 +12,8 @@ export interface ExamPreviewSidebarProps {
11
12
  progress: number;
12
13
  loading: boolean;
13
14
  showPartsDetail: boolean;
15
+ previewMode?: ExamPreviewMode;
14
16
  onTogglePartsDetail: () => void;
15
17
  onGoToPart: (index: number) => void;
16
18
  }
17
- export declare function ExamPreviewSidebar({ examName, duration, totalPoints, questions, questionsByPart, currentPart, currentPartIndex, totalParts, progress, loading, showPartsDetail, onTogglePartsDetail, onGoToPart, }: ExamPreviewSidebarProps): import("react").JSX.Element;
19
+ export declare function ExamPreviewSidebar({ examName, duration, totalPoints, questions, questionsByPart, currentPart, currentPartIndex, totalParts, progress, loading, showPartsDetail, previewMode, onTogglePartsDetail, onGoToPart, }: ExamPreviewSidebarProps): import("react").JSX.Element;
@@ -6,10 +6,15 @@ import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/ca
6
6
  import { Progress } from '../../components/ui/progress';
7
7
  import { Loader2 } from 'lucide-react';
8
8
  import { useT } from '../../shared/lib/i18n';
9
+ import { ExamPreviewMode } from './exam-preview.mode';
9
10
  import { getQuestionNumber, } from './exam-preview.types';
10
- export function ExamPreviewSidebar({ examName, duration, totalPoints, questions, questionsByPart, currentPart, currentPartIndex, totalParts, progress, loading, showPartsDetail, onTogglePartsDetail, onGoToPart, }) {
11
+ export function ExamPreviewSidebar({ examName, duration, totalPoints, questions, questionsByPart, currentPart, currentPartIndex, totalParts, progress, loading, showPartsDetail, previewMode = ExamPreviewMode.STUDENT, onTogglePartsDetail, onGoToPart, }) {
11
12
  const t = useT();
12
- return (_jsx("div", { className: "w-full overflow-y-auto border-b border-gray-200 sm:w-80 sm:border-b-0 sm:border-r", children: _jsxs("div", { className: "space-y-4 p-4", children: [_jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-3", children: _jsx(CardTitle, { className: "text-base", children: t('admin.exams.preview.examInfo') }) }), _jsxs(CardContent, { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm text-gray-600", children: t('admin.exams.preview.duration') }), _jsx("span", { className: "text-sm font-medium", children: t('admin.exams.view.minutes', { count: String(duration) }) })] }), _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm text-gray-600", children: t('admin.exams.preview.questionCount') }), _jsx("span", { className: "text-sm font-medium", children: loading
13
+ return (_jsx("div", { className: "w-full overflow-y-auto border-b border-gray-200 sm:w-80 sm:border-b-0 sm:border-r", children: _jsxs("div", { className: "space-y-4 p-4", children: [_jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-3", children: _jsx(CardTitle, { className: "text-base", children: t('admin.exams.preview.examInfo') }) }), _jsxs(CardContent, { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm text-gray-600", children: t('admin.exams.preview.mode.label') }), _jsx(Badge, { variant: "outline", className: previewMode === ExamPreviewMode.ANSWERS
14
+ ? 'border-emerald-300 bg-emerald-50 text-emerald-700 font-semibold'
15
+ : 'border-blue-300 bg-blue-50 text-blue-700 font-semibold', children: previewMode === ExamPreviewMode.ANSWERS
16
+ ? t('admin.exams.preview.mode.answers')
17
+ : t('admin.exams.preview.mode.student') })] }), _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm text-gray-600", children: t('admin.exams.preview.duration') }), _jsx("span", { className: "text-sm font-medium", children: t('admin.exams.view.minutes', { count: String(duration) }) })] }), _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm text-gray-600", children: t('admin.exams.preview.questionCount') }), _jsx("span", { className: "text-sm font-medium", children: loading
13
18
  ? '...'
14
19
  : t('admin.exams.preview.questions', { count: String(questions.length) }) })] }), _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm text-gray-600", children: t('admin.exams.preview.totalPoints') }), _jsx("span", { className: "text-sm font-medium", children: t('admin.exams.preview.points', { count: String(totalPoints) }) })] })] })] }), !loading && totalParts > 0 && (_jsx(Card, { children: _jsx(CardContent, { className: "p-4", children: _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex justify-between text-sm", children: [_jsx("span", { children: t('admin.exams.preview.progress') }), _jsxs("span", { className: "font-medium", children: [currentPartIndex + 1, "/", totalParts] })] }), _jsx(Progress, { value: progress, className: "h-2" })] }) }) })), loading ? (_jsx("div", { className: "flex items-center justify-center py-4", children: _jsxs("div", { className: "flex items-center gap-2 text-gray-500", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), _jsx("span", { className: "text-sm", children: t('admin.exams.preview.loading') })] }) })) : (totalParts > 0 && (_jsxs("div", { className: "space-y-3", children: [_jsx("h4", { className: "text-sm font-medium text-gray-700", children: t('admin.exams.preview.questionNavigation') }), _jsx("div", { className: "flex flex-wrap gap-2", children: questionsByPart.map((part, index) => {
15
20
  const isCurrent = index === currentPartIndex;
@@ -0,0 +1,4 @@
1
+ export declare enum ExamPreviewMode {
2
+ STUDENT = "student",
3
+ ANSWERS = "answers"
4
+ }
@@ -0,0 +1,5 @@
1
+ export var ExamPreviewMode;
2
+ (function (ExamPreviewMode) {
3
+ ExamPreviewMode["STUDENT"] = "student";
4
+ ExamPreviewMode["ANSWERS"] = "answers";
5
+ })(ExamPreviewMode || (ExamPreviewMode = {}));
@@ -32,5 +32,6 @@ export { EXAM_DIFFICULTY, ExamViewDialog } from './ExamViewDialog';
32
32
  export type { ExamDifficulty, ExamViewDialogExam, ExamViewDialogProps } from './ExamViewDialog';
33
33
  export { ExamPreviewDialog } from './ExamPreviewDialog';
34
34
  export type { ExamPreviewDialogProps } from './ExamPreviewDialog';
35
+ export { ExamPreviewMode } from './exam-preview.mode';
35
36
  export type { PreviewQuestion, QuestionsByPart } from './exam-preview.types';
36
37
  export { QuestionStatus, clearQuestionStatuses, getQuestionStatus, initializeQuestionStatusesFromAPI, setQuestionStatus } from './exam-question-status';
@@ -16,4 +16,5 @@ export { UnsavedChangesDialog } from './UnsavedChangesDialog';
16
16
  export { CompletionDialog } from './CompletionDialog';
17
17
  export { EXAM_DIFFICULTY, ExamViewDialog } from './ExamViewDialog';
18
18
  export { ExamPreviewDialog } from './ExamPreviewDialog';
19
+ export { ExamPreviewMode } from './exam-preview.mode';
19
20
  export { QuestionStatus, clearQuestionStatuses, getQuestionStatus, initializeQuestionStatusesFromAPI, setQuestionStatus } from './exam-question-status';
@@ -10,7 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  */
11
11
  import { MoversListenAndWriteRenderer, MoversChooseBestAnswerRenderer, MoversChooseAdjectiveRenderer, MoversWritingRenderer, MoversColoringRenderer, MoversPictureChooseRenderer, MoversPictureFillBlankChooseRenderer, MoversReadingPassageRenderer, MoversLabelThePictureRenderer, MoversFillWordHintRenderer, MoversGridFillRenderer, MoversTrueFalseRenderer, MoversCrossOutWordGroupRenderer, MoversAnswerTheQuestionRenderer, MoversWriteSentencesRenderer, MoversWordOrderingRenderer, MoversWordFillParagraphRenderer, MoversMatchByWritingAnswerRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderAndMatchGroupRenderer, } from './question-renderers';
12
12
  import { SpeakingQuestionRenderer } from './speaking/renderers';
13
- import { transformFillInBlank, transformWriteCorrectVerbForm, transformChooseCorrectAnswer, transformChooseTheCorrectAnswerGroup, transformChooseCorrectAdjective, transformWriteShortLetter, transformColorObjects, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformLabelPicture, transformFillWordHint, transformGridFill, transformAnswerTheQuestion, transformTrueFalse, transformTrueFalseGroup, transformWordOrdering, transformWordFillParagraph, transformMatchByWritingAnswer, transformWriteSentences, transformWordFillStructuredForm, transformWordOrderAndMatchGroup, } from '../utils/question-transformers';
13
+ import { transformFillInBlank, transformWriteCorrectVerbForm, transformChooseCorrectAnswer, transformChooseTheCorrectAnswerGroup, transformChooseCorrectAdjective, transformWriteShortLetter, transformColorObjects, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformLabelPicture, transformFillWordHint, transformGridFill, transformAnswerTheQuestion, transformAnswerTheQuestionGroup, transformTrueFalse, transformTrueFalseGroup, transformWordOrdering, transformWordFillParagraph, transformMatchByWritingAnswer, transformWriteSentences, transformWordFillStructuredForm, transformWordOrderAndMatchGroup, } from '../utils/question-transformers';
14
14
  import { transformCrossOutWordGroup } from '../utils/cross-out-word-group.transformer';
15
15
  import { normalizeColoringAssignments } from '../utils/coloring-assignments';
16
16
  export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode = false, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex, }) {
@@ -137,6 +137,10 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
137
137
  const data = transformAnswerTheQuestion(questions);
138
138
  return (_jsx(MoversAnswerTheQuestionRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, imageUrl: data.imageUrl, audioUrl: part.audioUrl, title: data.title, groupContent: data.groupContent, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
139
139
  }
140
+ case 'ANSWER_THE_QUESTION_GROUP': {
141
+ const data = transformAnswerTheQuestionGroup(questions);
142
+ return (_jsx(MoversAnswerTheQuestionRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, groupContent: data.groupContent, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
143
+ }
140
144
  case 'TRUE_FALSE_GROUP': {
141
145
  const data = transformTrueFalseGroup(questions);
142
146
  return (_jsx(MoversTrueFalseRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, imageUrl: data.imageUrl, groupContent: data.groupContent, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
@@ -88,7 +88,8 @@ function findParentQuestion(subQuestionId, parts) {
88
88
  part.questionType === 'CROSS_OUT_WORD_GROUP' ||
89
89
  part.questionType === 'TRUE_FALSE_GROUP' ||
90
90
  part.questionType === 'WORD_ORDER_AND_MATCH_GROUP' ||
91
- part.questionType === 'SPONTANEOUS_QA_GROUP') {
91
+ part.questionType === 'SPONTANEOUS_QA_GROUP' ||
92
+ part.questionType === 'ANSWER_THE_QUESTION_GROUP') {
92
93
  const parent = part.questions.find((question) => subQuestionId.startsWith(`${question.id}-item-`));
93
94
  if (parent) {
94
95
  return {
@@ -5,7 +5,7 @@
5
5
  * Each transformer handles specific question type data structure.
6
6
  */
7
7
  import type { FillBlankQuestion, ChooseBestAnswerQuestion, PictureChooseSubQuestion, PictureFillBlankSubQuestion, ReadingPassageQuestion, LabelThePictureLabel, FillWordHintSubQuestion, ColoringHintItem, ColoringRegion } from '../types';
8
- export type QuestionType = 'FILL_IN_BLANK' | 'WRITE_CORRECT_VERB_FORM' | 'CHOOSE_THE_CORRECT_ANSWER' | 'CHOOSE_THE_CORRECT_ANSWER_GROUP' | 'CHOOSE_CORRECT_ADJECTIVE' | 'WRITE_A_SHORT_LETTER' | 'READ_AND_COLOR_OBJECTS' | 'LOOK_PICTURE_CHOOSE_CORRECT_ANSWER' | 'LOOK_PICTURE_FILL_BLANK_CHOOSE_ANSWER' | 'READ_PASSAGE_AND_ANSWER_QUESTIONS' | 'LABEL_THE_PICTURE' | 'LOOK_PICTURE_FILL_WORD_HINT' | 'FILL_MISSING_WORDS_IN_GRID' | 'IMAGE_OBJECT_MATCHING' | 'ANSWER_THE_QUESTION' | 'TRUE_FALSE' | 'TRUE_FALSE_GROUP' | 'CROSS_OUT_WORD_GROUP' | 'WORD_ORDERING' | 'WORD_FILL_PARAGRAPH' | 'MATCH_BY_WRITING_ANSWER' | 'WRITE_SENTENCES' | 'WORD_FILL_STRUCTURED_FORM' | 'WORD_ORDER_AND_MATCH_GROUP' | 'LISTEN_AND_CHOOSE_OBJECTS_IN_SCENE' | 'LISTEN_AND_CHOOSE_FROM_ANSWER_GROUP' | 'LISTEN_AND_DRAG_OBJECTS_INTO_SCENE' | 'LISTEN_AND_SPEAK_ANSWER' | 'LISTEN_AND_SPEAK_COMPARE_IMAGES' | 'LISTEN_AND_SPEAK_WITH_STORY_IMAGES' | 'LISTEN_AND_SPEAK_IMAGE_GROUP' | 'LISTEN_AND_SPEAK_ODD_ONE_OUT' | 'LISTEN_AND_SPEAK_QUESTION_LIST' | 'LISTEN_AND_SPEAK_INFO_EXCHANGE' | 'READ_DISPLAYED_CONTENT' | 'SPEAKING_DESCRIBE_IMAGE' | 'SPONTANEOUS_QA' | 'SPONTANEOUS_QA_GROUP' | 'SPEAKING_CONVERSATION' | 'GN_SPEAKING_INTERVIEW' | 'SPEAKING_CUE_CARD';
8
+ export type QuestionType = 'FILL_IN_BLANK' | 'WRITE_CORRECT_VERB_FORM' | 'CHOOSE_THE_CORRECT_ANSWER' | 'CHOOSE_THE_CORRECT_ANSWER_GROUP' | 'CHOOSE_CORRECT_ADJECTIVE' | 'WRITE_A_SHORT_LETTER' | 'READ_AND_COLOR_OBJECTS' | 'LOOK_PICTURE_CHOOSE_CORRECT_ANSWER' | 'LOOK_PICTURE_FILL_BLANK_CHOOSE_ANSWER' | 'READ_PASSAGE_AND_ANSWER_QUESTIONS' | 'LABEL_THE_PICTURE' | 'LOOK_PICTURE_FILL_WORD_HINT' | 'FILL_MISSING_WORDS_IN_GRID' | 'IMAGE_OBJECT_MATCHING' | 'ANSWER_THE_QUESTION' | 'ANSWER_THE_QUESTION_GROUP' | 'TRUE_FALSE' | 'TRUE_FALSE_GROUP' | 'CROSS_OUT_WORD_GROUP' | 'WORD_ORDERING' | 'WORD_FILL_PARAGRAPH' | 'MATCH_BY_WRITING_ANSWER' | 'WRITE_SENTENCES' | 'WORD_FILL_STRUCTURED_FORM' | 'WORD_ORDER_AND_MATCH_GROUP' | 'LISTEN_AND_CHOOSE_OBJECTS_IN_SCENE' | 'LISTEN_AND_CHOOSE_FROM_ANSWER_GROUP' | 'LISTEN_AND_DRAG_OBJECTS_INTO_SCENE' | 'LISTEN_AND_SPEAK_ANSWER' | 'LISTEN_AND_SPEAK_COMPARE_IMAGES' | 'LISTEN_AND_SPEAK_WITH_STORY_IMAGES' | 'LISTEN_AND_SPEAK_IMAGE_GROUP' | 'LISTEN_AND_SPEAK_ODD_ONE_OUT' | 'LISTEN_AND_SPEAK_QUESTION_LIST' | 'LISTEN_AND_SPEAK_INFO_EXCHANGE' | 'READ_DISPLAYED_CONTENT' | 'SPEAKING_DESCRIBE_IMAGE' | 'SPONTANEOUS_QA' | 'SPONTANEOUS_QA_GROUP' | 'SPEAKING_CONVERSATION' | 'GN_SPEAKING_INTERVIEW' | 'SPEAKING_CUE_CARD';
9
9
  export interface ApiQuestion {
10
10
  id: string;
11
11
  content: any;
@@ -244,6 +244,11 @@ export declare function transformAnswerTheQuestion(questions: ApiQuestion[]): {
244
244
  title?: string;
245
245
  groupContent?: string;
246
246
  };
247
+ export declare function transformAnswerTheQuestionGroup(questions: ApiQuestion[]): {
248
+ questions: AnswerTheQuestionQuestion[];
249
+ instruction: string;
250
+ groupContent?: string;
251
+ };
247
252
  export interface TrueFalseQuestion {
248
253
  id: string;
249
254
  questionNumber: number;
@@ -804,6 +804,39 @@ export function transformAnswerTheQuestion(questions) {
804
804
  groupContent: groupPayload?.groupContent || firstContent?.groupContent,
805
805
  };
806
806
  }
807
+ // ============================================
808
+ // ANSWER_THE_QUESTION_GROUP Transformer
809
+ // ============================================
810
+ export function transformAnswerTheQuestionGroup(questions) {
811
+ const parent = questions[0];
812
+ const content = parent?.content;
813
+ const items = Array.isArray(content?.items) ? content.items : [];
814
+ const parentCorrect = Array.isArray(parent?.correct_answer?.answer)
815
+ ? parent.correct_answer.answer
816
+ : [];
817
+ const transformed = items.map((item, itemIndex) => {
818
+ const isExample = item.isExample === true;
819
+ const fallbackAnswers = Array.isArray(parentCorrect[itemIndex]?.expectedAnswers)
820
+ ? parentCorrect[itemIndex].expectedAnswers
821
+ : [];
822
+ const nestedAnswers = Array.isArray(item.correctAnswer?.expectedAnswers)
823
+ ? item.correctAnswer.expectedAnswers
824
+ : [];
825
+ const exampleAnswer = (nestedAnswers[0] || fallbackAnswers[0] || '');
826
+ return {
827
+ id: `${parent.id}-item-${itemIndex}`,
828
+ questionNumber: item.questionNumber || itemIndex + 1,
829
+ question: item.content?.question || '',
830
+ isExample,
831
+ exampleAnswer: isExample ? exampleAnswer : undefined,
832
+ };
833
+ });
834
+ return {
835
+ questions: transformed,
836
+ instruction: content?.meta?.instruction || 'Read the text again and answer the questions.',
837
+ groupContent: content?.meta?.passage,
838
+ };
839
+ }
807
840
  export function transformTrueFalse(questions) {
808
841
  const groupPayload = questions[0]?.questionGroup?.payload;
809
842
  const transformed = questions.map((q) => {
@@ -207,6 +207,11 @@ export const QUESTION_TYPE_CONFIG = {
207
207
  requiresContent: true,
208
208
  hasDedicatedComponent: true,
209
209
  },
210
+ ANSWER_THE_QUESTION_GROUP: {
211
+ label: 'Đọc và trả lời câu hỏi (AI)',
212
+ requiresContent: true,
213
+ hasDedicatedComponent: true,
214
+ },
210
215
  WORD_ORDERING: {
211
216
  label: 'Sắp xếp từ',
212
217
  requiresContent: true,
@@ -333,6 +338,7 @@ export const QUESTION_TYPES_WITH_DEDICATED_COMPONENTS = [
333
338
  'WRITE_CORRECT_VERB_FORM',
334
339
  'CHOOSE_CORRECT_ADJECTIVE',
335
340
  'ANSWER_THE_QUESTION',
341
+ 'ANSWER_THE_QUESTION_GROUP',
336
342
  'TRUE_FALSE',
337
343
  'WORD_ORDERING',
338
344
  'WORD_FILL_PARAGRAPH',
@@ -0,0 +1,32 @@
1
+ import type { MutableRefObject } from 'react';
2
+ export interface AnswerTheQuestionGroupItemData {
3
+ question: string;
4
+ expectedAnswers: string[];
5
+ isExample?: boolean;
6
+ points: number;
7
+ questionNumber: number;
8
+ }
9
+ export interface AnswerTheQuestionGroupData {
10
+ instruction: string;
11
+ passage?: string;
12
+ items: AnswerTheQuestionGroupItemData[];
13
+ explanation?: string;
14
+ points?: number;
15
+ }
16
+ export interface AnswerTheQuestionGroupCreatorProps {
17
+ initialData?: AnswerTheQuestionGroupData;
18
+ onSave?: (data: AnswerTheQuestionGroupData) => void;
19
+ onCancel?: () => void;
20
+ onChange?: (data: AnswerTheQuestionGroupData) => void;
21
+ externalErrors?: string[];
22
+ onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
23
+ validationRef?: MutableRefObject<{
24
+ triggerValidation: () => Promise<boolean>;
25
+ getFormData?: () => AnswerTheQuestionGroupData;
26
+ } | null>;
27
+ }
28
+ export interface AnswerTheQuestionGroupClientProps {
29
+ questionData: AnswerTheQuestionGroupData;
30
+ isReviewMode?: boolean;
31
+ userAnswers?: string[];
32
+ }
@@ -18,6 +18,7 @@ import { writeAShortLetterRegistration } from '../types/write-a-short-letter/reg
18
18
  import { writeCorrectVerbFormRegistration } from '../types/write-correct-verb-form/register';
19
19
  import { chooseCorrectAdjectiveRegistration } from '../types/choose-correct-adjective/register';
20
20
  import { answerTheQuestionRegistration } from '../types/answer-the-question/register';
21
+ import { answerTheQuestionGroupRegistration } from '../types/answer-the-question-group/register';
21
22
  import { trueFalseRegistration } from '../types/true-false/register';
22
23
  import { wordOrderingRegistration } from '../types/word-ordering/register';
23
24
  import { wordFillParagraphRegistration } from '../types/word-fill-paragraph/register';
@@ -64,6 +65,7 @@ export const questionTypeRegistry = {
64
65
  WRITE_CORRECT_VERB_FORM: writeCorrectVerbFormRegistration,
65
66
  CHOOSE_CORRECT_ADJECTIVE: chooseCorrectAdjectiveRegistration,
66
67
  ANSWER_THE_QUESTION: answerTheQuestionRegistration,
68
+ ANSWER_THE_QUESTION_GROUP: answerTheQuestionGroupRegistration,
67
69
  TRUE_FALSE: trueFalseRegistration,
68
70
  WORD_ORDERING: wordOrderingRegistration,
69
71
  WORD_FILL_PARAGRAPH: wordFillParagraphRegistration, // Will need to handle viewMode mapping either here or in wrapper
@@ -0,0 +1,2 @@
1
+ import type { AnswerTheQuestionGroupClientProps } from '../../_shared/types/answer-the-question-group.type';
2
+ export declare function AnswerTheQuestionGroupClient({ questionData, isReviewMode, userAnswers, }: AnswerTheQuestionGroupClientProps): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { BookOpen, CircleHelp, Lightbulb, Sparkles, Star } from 'lucide-react';
4
+ export function AnswerTheQuestionGroupClient({ questionData, isReviewMode = false, userAnswers = [], }) {
5
+ const items = questionData.items || [];
6
+ return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.passage && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none leading-relaxed text-gray-800", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
7
+ const userValue = userAnswers[itemIndex] || '';
8
+ const expectedAnswers = Array.isArray(item.expectedAnswers)
9
+ ? item.expectedAnswers.filter((answer) => answer.trim() !== '')
10
+ : [];
11
+ return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] })), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-violet-100 px-2 py-0.5 text-xs font-semibold text-violet-700", children: [_jsx(Sparkles, { className: "h-3 w-3" }), "AI"] })] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "space-y-3 p-4", children: [_jsx("p", { className: "text-sm font-semibold leading-relaxed text-gray-800", children: item.question || 'Câu hỏi chưa được nhập' }), _jsx("div", { className: "rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700", children: userValue || (isReviewMode ? '(Chưa trả lời)' : 'Học sinh sẽ nhập câu trả lời tại đây') }), isReviewMode && expectedAnswers.length > 0 && (_jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-xs font-medium text-gray-500", children: "\u0110\u00E1p \u00E1n m\u1EABu (AI)" }), _jsx("ul", { className: "list-disc space-y-1 pl-5 text-sm text-gray-700", children: expectedAnswers.map((answer) => (_jsx("li", { children: answer }, answer))) })] }))] })] }, `${item.questionNumber}-${itemIndex}`));
12
+ }), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
13
+ }
@@ -0,0 +1,2 @@
1
+ import type { AnswerTheQuestionGroupCreatorProps } from '../../_shared/types/answer-the-question-group.type';
2
+ export declare function AnswerTheQuestionGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }: AnswerTheQuestionGroupCreatorProps): import("react").JSX.Element;
@@ -0,0 +1,183 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { Button } from '../../../../components/ui/button';
5
+ import { Label } from '../../../../components/ui/label';
6
+ import { Input } from '../../../../components/ui/input';
7
+ import { Textarea } from '../../../../components/ui/textarea';
8
+ import { PointsInput } from '../../../../components/ui/points-input';
9
+ import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
10
+ import { Switch } from '../../../../components/ui/switch';
11
+ import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
12
+ import { useDebouncedCallback } from '../../../../shared/lib/hooks';
13
+ import { Eye, Pencil, Plus, Sparkles, Star, Trash2 } from 'lucide-react';
14
+ import { AnswerTheQuestionGroupClient } from './AnswerTheQuestionGroupClient';
15
+ const DEFAULT_INSTRUCTION = 'Read the text again and answer the questions.';
16
+ function createEmptyItem(questionNumber) {
17
+ return {
18
+ question: '',
19
+ expectedAnswers: [''],
20
+ isExample: false,
21
+ points: 1,
22
+ questionNumber,
23
+ };
24
+ }
25
+ function normalizeExpectedAnswers(value) {
26
+ if (!Array.isArray(value) || value.length === 0) {
27
+ return [''];
28
+ }
29
+ return value.map((item) => (typeof item === 'string' ? item : ''));
30
+ }
31
+ function normalizeItems(rawItems) {
32
+ if (!Array.isArray(rawItems) || rawItems.length === 0) {
33
+ return [createEmptyItem(1)];
34
+ }
35
+ return rawItems.map((item, index) => ({
36
+ question: item.question || '',
37
+ expectedAnswers: normalizeExpectedAnswers(item.expectedAnswers),
38
+ isExample: item.isExample === true,
39
+ points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
40
+ questionNumber: item.questionNumber || index + 1,
41
+ }));
42
+ }
43
+ function sumPoints(items) {
44
+ return items.reduce((total, item) => (item.isExample ? total : total + (typeof item.points === 'number' ? item.points : 1)), 0);
45
+ }
46
+ function isContentEmpty(html) {
47
+ return !html || html === '<p></p>' || html === '<p><br></p>' || html.trim() === '';
48
+ }
49
+ export function AnswerTheQuestionGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
50
+ const [instruction, setInstruction] = useState(initialData?.instruction || DEFAULT_INSTRUCTION);
51
+ const [passage, setPassage] = useState(initialData?.passage || '');
52
+ const [items, setItems] = useState(() => normalizeItems(initialData?.items));
53
+ const [explanation, setExplanation] = useState(initialData?.explanation || '');
54
+ const [isClientMode, setIsClientMode] = useState(false);
55
+ const [errors, setErrors] = useState([]);
56
+ const onChangeRef = useRef(onChange);
57
+ const previousPayloadRef = useRef('');
58
+ useEffect(() => {
59
+ onChangeRef.current = onChange;
60
+ }, [onChange]);
61
+ const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation) => ({
62
+ instruction: nextInstruction,
63
+ ...(!isContentEmpty(nextPassage) ? { passage: nextPassage.trim() } : {}),
64
+ items: nextItems,
65
+ explanation: nextExplanation,
66
+ points: sumPoints(nextItems),
67
+ });
68
+ const debouncedOnChange = useDebouncedCallback((payload) => {
69
+ onChangeRef.current?.(payload);
70
+ }, 300);
71
+ useEffect(() => {
72
+ const payload = buildPayload();
73
+ const serialized = JSON.stringify(payload);
74
+ if (serialized === previousPayloadRef.current) {
75
+ return;
76
+ }
77
+ previousPayloadRef.current = serialized;
78
+ onUnsavedChangesChange?.(true);
79
+ debouncedOnChange(payload);
80
+ }, [instruction, passage, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
81
+ const validate = () => {
82
+ const nextErrors = [];
83
+ if (!instruction.trim()) {
84
+ nextErrors.push('Vui lòng nhập hướng dẫn.');
85
+ }
86
+ if (items.length < 1) {
87
+ nextErrors.push('Cần ít nhất một câu hỏi.');
88
+ }
89
+ items.forEach((item, index) => {
90
+ if (!item.question.trim()) {
91
+ nextErrors.push(`Câu ${index + 1}: nội dung câu hỏi là bắt buộc.`);
92
+ }
93
+ const filledAnswers = item.expectedAnswers
94
+ .map((answer) => answer.trim())
95
+ .filter((answer) => answer !== '');
96
+ if (!item.isExample && filledAnswers.length === 0) {
97
+ nextErrors.push(`Câu ${index + 1}: cần ít nhất một đáp án mẫu.`);
98
+ }
99
+ });
100
+ return nextErrors;
101
+ };
102
+ useEffect(() => {
103
+ if (!validationRef) {
104
+ return;
105
+ }
106
+ validationRef.current = {
107
+ triggerValidation: async () => {
108
+ const nextErrors = validate();
109
+ setErrors(nextErrors);
110
+ return nextErrors.length === 0;
111
+ },
112
+ getFormData: () => buildPayload(),
113
+ };
114
+ }, [validationRef, instruction, passage, items, explanation]);
115
+ const updateItem = (index, patch) => {
116
+ setItems((prev) => {
117
+ const next = [...prev];
118
+ const current = next[index];
119
+ if (!current)
120
+ return prev;
121
+ next[index] = { ...current, ...patch };
122
+ return next;
123
+ });
124
+ };
125
+ const updateExpectedAnswer = (itemIndex, answerIndex, value) => {
126
+ setItems((prev) => {
127
+ const next = [...prev];
128
+ const current = next[itemIndex];
129
+ if (!current)
130
+ return prev;
131
+ const expectedAnswers = [...current.expectedAnswers];
132
+ expectedAnswers[answerIndex] = value;
133
+ next[itemIndex] = { ...current, expectedAnswers };
134
+ return next;
135
+ });
136
+ };
137
+ const addExpectedAnswer = (itemIndex) => {
138
+ setItems((prev) => {
139
+ const next = [...prev];
140
+ const current = next[itemIndex];
141
+ if (!current)
142
+ return prev;
143
+ next[itemIndex] = {
144
+ ...current,
145
+ expectedAnswers: [...current.expectedAnswers, ''],
146
+ };
147
+ return next;
148
+ });
149
+ };
150
+ const removeExpectedAnswer = (itemIndex, answerIndex) => {
151
+ setItems((prev) => {
152
+ const next = [...prev];
153
+ const current = next[itemIndex];
154
+ if (!current || current.expectedAnswers.length <= 1)
155
+ return prev;
156
+ next[itemIndex] = {
157
+ ...current,
158
+ expectedAnswers: current.expectedAnswers.filter((_, index) => index !== answerIndex),
159
+ };
160
+ return next;
161
+ });
162
+ };
163
+ const addItem = () => {
164
+ setItems((prev) => [...prev, createEmptyItem(prev.length + 1)]);
165
+ };
166
+ const removeItem = (index) => {
167
+ setItems((prev) => {
168
+ if (prev.length <= 1)
169
+ return prev;
170
+ return prev
171
+ .filter((_, itemIndex) => itemIndex !== index)
172
+ .map((item, itemIndex) => ({ ...item, questionNumber: itemIndex + 1 }));
173
+ });
174
+ };
175
+ const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
176
+ if (isClientMode) {
177
+ return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(false), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), "Ch\u1EBF \u0111\u1ED9 ch\u1EC9nh s\u1EEDa"] }) }), _jsx(AnswerTheQuestionGroupClient, { questionData: buildPayload(), isReviewMode: true })] }));
178
+ }
179
+ return (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "\u0110\u1ECDc v\u00E0 tr\u1EA3 l\u1EDDi c\u00E2u h\u1ECFi (AI)" }), _jsx("p", { className: "text-sm text-gray-500", children: "Nh\u00F3m c\u00E2u \u0111\u1ECDc hi\u1EC3u l\u01B0u 1 row. AI ch\u1EA5m theo question + expectedAnswers." })] }), _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(true), children: [_jsx(Eye, { className: "mr-2 h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Nh\u00F3m \u0111\u1ECDc hi\u1EC3u" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "group-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: DEFAULT_INSTRUCTION })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsxs("div", { className: "rounded-xl border border-violet-100 bg-violet-50 p-4 text-sm text-violet-800", children: [_jsxs("div", { className: "flex items-center gap-2 font-medium", children: [_jsx(Sparkles, { className: "h-4 w-4" }), "Ch\u1EA5m \u0111i\u1EC3m b\u1EB1ng AI"] }), _jsx("p", { className: "mt-1 text-xs text-violet-700", children: "H\u1ECDc sinh vi\u1EBFt c\u00E2u tr\u1EA3 l\u1EDDi. AI so v\u1EDBi b\u1EA5t k\u1EF3 \u0111\u00E1p \u00E1n m\u1EABu n\u00E0o c\u1EE7a t\u1EEBng c\u00E2u (binary 0/1)." })] }), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gray-50 p-4", children: [_jsx(PointsInput, { value: sumPoints(items), allowZero: true, readOnly: true }), _jsx("p", { className: "mt-2 text-xs text-gray-500", children: "T\u1ED5ng \u0111i\u1EC3m \u0111\u01B0\u1EE3c c\u1ED9ng t\u1EEB c\u00E1c c\u00E2u kh\u00F4ng ph\u1EA3i example." })] })] })] }), items.map((item, itemIndex) => (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [_jsxs(CardTitle, { className: "text-base", children: ["C\u00E2u ", itemIndex + 1] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(itemIndex), disabled: items.length <= 1, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-amber-800", children: [_jsx(Star, { className: "h-4 w-4" }), "C\u00E2u v\u00ED d\u1EE5"] }), _jsx(Switch, { checked: item.isExample === true, onCheckedChange: (checked) => updateItem(itemIndex, {
180
+ isExample: checked,
181
+ points: checked ? 0 : (item.points || 1),
182
+ }) })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "C\u00E2u h\u1ECFi" }), _jsx(Textarea, { value: item.question, onChange: (event) => updateItem(itemIndex, { question: event.target.value }), rows: 2, placeholder: "What is autism?" })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "\u0110\u00E1p \u00E1n m\u1EABu cho AI" }), item.expectedAnswers.map((answer, answerIndex) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { value: answer, onChange: (event) => updateExpectedAnswer(itemIndex, answerIndex, event.target.value), placeholder: `Đáp án mẫu ${answerIndex + 1}` }), item.expectedAnswers.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeExpectedAnswer(itemIndex, answerIndex), children: _jsx(Trash2, { className: "h-4 w-4" }) }))] }, `expected-${itemIndex}-${answerIndex}`))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => addExpectedAnswer(itemIndex), children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), "Th\u00EAm \u0111\u00E1p \u00E1n m\u1EABu"] })] }), !item.isExample && (_jsx(PointsInput, { value: item.points, allowZero: true, onChange: (e) => updateItem(itemIndex, { points: Number(e.target.value) || 0 }) }))] })] }, `item-${itemIndex}`))), _jsxs(Button, { type: "button", variant: "outline", onClick: addItem, children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), "Th\u00EAm c\u00E2u h\u1ECFi"] }), displayErrors.length > 0 && (_jsx("div", { className: "rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700", children: displayErrors.map((error) => (_jsx("p", { children: error }, error))) }))] }));
183
+ }
@@ -0,0 +1,5 @@
1
+ export * from './AnswerTheQuestionGroupCreator';
2
+ export * from './AnswerTheQuestionGroupClient';
3
+ export * from './transform';
4
+ export * from './register';
5
+ export * from './map-answer-the-question-group-data';
@@ -0,0 +1,5 @@
1
+ export * from './AnswerTheQuestionGroupCreator';
2
+ export * from './AnswerTheQuestionGroupClient';
3
+ export * from './transform';
4
+ export * from './register';
5
+ export * from './map-answer-the-question-group-data';