greeta commited on
Commit
393e843
·
verified ·
1 Parent(s): f69d64f

Upload TasksTab.tsx

Browse files
Files changed (1) hide show
  1. TasksTab.tsx +516 -0
TasksTab.tsx ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import type { Session } from '@supabase/supabase-js';
3
+
4
+ import Loader from './Loader';
5
+ import { isSupabaseConfigured, supabase } from '../services/supabaseClient';
6
+
7
+ interface FipiTask {
8
+ id: number;
9
+ title: string;
10
+ content: string;
11
+ task_type: string | null;
12
+ source_url: string;
13
+ scraped_at: string;
14
+ variants?: string[];
15
+ can_check_answer?: boolean;
16
+ }
17
+
18
+ interface TaskAttempt {
19
+ id?: number;
20
+ task_id: number;
21
+ submitted_answer: string;
22
+ is_correct: boolean;
23
+ check_status: string;
24
+ created_at: string;
25
+ }
26
+
27
+ interface CheckAnswerResult {
28
+ success: boolean;
29
+ is_correct: boolean;
30
+ status_code: string;
31
+ status_label: string;
32
+ submitted_answer: string;
33
+ normalized_answer: string;
34
+ message: string;
35
+ }
36
+
37
+ interface TasksTabProps {
38
+ session: Session | null;
39
+ scraperUrl?: string;
40
+ }
41
+
42
+ const FILTERS = ['all', 'checkable', 'writing', 'test', 'reading', 'listening', 'other'] as const;
43
+
44
+ const sourceLabel = (task: FipiTask) => {
45
+ if (task.source_url.includes('questions.php')) return 'Банк ФИПИ';
46
+ if (task.source_url.includes('otkrytyye-varianty-kim-ege')) return 'Открытый вариант';
47
+ if (task.source_url.includes('demoversii-specifikacii-kodifikatory')) return 'Демоверсия';
48
+ return 'Источник ФИПИ';
49
+ };
50
+
51
+ const TasksTab: React.FC<TasksTabProps> = ({
52
+ session,
53
+ scraperUrl = import.meta.env.VITE_SCRAPER_URL || 'http://localhost:8000',
54
+ }) => {
55
+ const [tasks, setTasks] = useState<FipiTask[]>([]);
56
+ const [attempts, setAttempts] = useState<TaskAttempt[]>([]);
57
+ const [localAttempts, setLocalAttempts] = useState<TaskAttempt[]>([]);
58
+ const [answerDrafts, setAnswerDrafts] = useState<Record<number, string>>({});
59
+ const [checkResults, setCheckResults] = useState<Record<number, CheckAnswerResult>>({});
60
+ const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null);
61
+ const [filterType, setFilterType] = useState<(typeof FILTERS)[number]>('checkable');
62
+ const [search, setSearch] = useState('');
63
+ const [loading, setLoading] = useState(true);
64
+ const [statsLoading, setStatsLoading] = useState(false);
65
+ const [error, setError] = useState<string | null>(null);
66
+ const [checkingTaskId, setCheckingTaskId] = useState<number | null>(null);
67
+
68
+ const fetchTasks = async () => {
69
+ setLoading(true);
70
+ setError(null);
71
+ try {
72
+ const response = await fetch(`${scraperUrl}/api/tasks`);
73
+ if (!response.ok) {
74
+ throw new Error('Не удалось загрузить задания.');
75
+ }
76
+ const data = await response.json();
77
+ const taskList = (Array.isArray(data) ? data : data.tasks || []) as FipiTask[];
78
+ setTasks(taskList);
79
+ } catch (err) {
80
+ setError(err instanceof Error ? err.message : 'Не удалось загрузить задания.');
81
+ } finally {
82
+ setLoading(false);
83
+ }
84
+ };
85
+
86
+ const fetchAttempts = async () => {
87
+ if (!session || !isSupabaseConfigured()) {
88
+ setAttempts([]);
89
+ return;
90
+ }
91
+
92
+ setStatsLoading(true);
93
+ try {
94
+ const { data, error: selectError } = await supabase
95
+ .from('task_attempts')
96
+ .select('*')
97
+ .eq('user_id', session.user.id)
98
+ .order('created_at', { ascending: false });
99
+
100
+ if (selectError) {
101
+ throw selectError;
102
+ }
103
+ setAttempts((data || []) as TaskAttempt[]);
104
+ } catch {
105
+ setAttempts([]);
106
+ } finally {
107
+ setStatsLoading(false);
108
+ }
109
+ };
110
+
111
+ useEffect(() => {
112
+ fetchTasks();
113
+ }, [scraperUrl]);
114
+
115
+ useEffect(() => {
116
+ fetchAttempts();
117
+ }, [session]);
118
+
119
+ const allAttempts = session ? attempts : localAttempts;
120
+
121
+ const latestAttemptByTask = useMemo(() => {
122
+ const latest = new Map<number, TaskAttempt>();
123
+ for (const attempt of allAttempts) {
124
+ if (!latest.has(attempt.task_id)) {
125
+ latest.set(attempt.task_id, attempt);
126
+ }
127
+ }
128
+ return latest;
129
+ }, [allAttempts]);
130
+
131
+ const taskStats = useMemo(() => {
132
+ const totalAttempts = allAttempts.length;
133
+ const incorrectAttempts = allAttempts.filter((attempt) => !attempt.is_correct).length;
134
+ const correctAttempts = totalAttempts - incorrectAttempts;
135
+ const solvedTasks = new Set(allAttempts.filter((attempt) => attempt.is_correct).map((attempt) => attempt.task_id)).size;
136
+ const accuracy = totalAttempts === 0 ? 0 : Math.round((correctAttempts / totalAttempts) * 100);
137
+
138
+ const wrongByTask = new Map<number, number>();
139
+ for (const attempt of allAttempts) {
140
+ if (!attempt.is_correct) {
141
+ wrongByTask.set(attempt.task_id, (wrongByTask.get(attempt.task_id) || 0) + 1);
142
+ }
143
+ }
144
+
145
+ const hardestTasks = Array.from(wrongByTask.entries())
146
+ .sort((left, right) => right[1] - left[1])
147
+ .slice(0, 5)
148
+ .map(([taskId, wrongCount]) => ({
149
+ task: tasks.find((task) => task.id === taskId),
150
+ wrongCount,
151
+ }))
152
+ .filter((item) => item.task);
153
+
154
+ return {
155
+ totalAttempts,
156
+ incorrectAttempts,
157
+ correctAttempts,
158
+ solvedTasks,
159
+ accuracy,
160
+ hardestTasks,
161
+ };
162
+ }, [allAttempts, tasks]);
163
+
164
+ const filteredTasks = useMemo(() => {
165
+ const normalizedQuery = search.trim().toLowerCase();
166
+ return tasks
167
+ .filter((task) => {
168
+ if (filterType === 'checkable') {
169
+ return Boolean(task.can_check_answer);
170
+ }
171
+ if (filterType !== 'all') {
172
+ return task.task_type === filterType;
173
+ }
174
+ return true;
175
+ })
176
+ .filter((task) => {
177
+ if (!normalizedQuery) {
178
+ return true;
179
+ }
180
+ return (
181
+ task.title.toLowerCase().includes(normalizedQuery) ||
182
+ task.content.toLowerCase().includes(normalizedQuery)
183
+ );
184
+ })
185
+ .sort((left, right) => Number(Boolean(right.can_check_answer)) - Number(Boolean(left.can_check_answer)));
186
+ }, [filterType, search, tasks]);
187
+
188
+ const submitAnswer = async (task: FipiTask) => {
189
+ const draft = (answerDrafts[task.id] || '').trim();
190
+ if (!draft) {
191
+ setCheckResults((current) => ({
192
+ ...current,
193
+ [task.id]: {
194
+ success: false,
195
+ is_correct: false,
196
+ status_code: 'validation_error',
197
+ status_label: 'Пустой ответ',
198
+ submitted_answer: draft,
199
+ normalized_answer: '',
200
+ message: 'Введите ответ перед проверкой.',
201
+ },
202
+ }));
203
+ return;
204
+ }
205
+
206
+ setCheckingTaskId(task.id);
207
+ try {
208
+ const response = await fetch(`${scraperUrl}/api/tasks/${task.id}/check-answer`, {
209
+ method: 'POST',
210
+ headers: { 'Content-Type': 'application/json' },
211
+ body: JSON.stringify({ answer: draft }),
212
+ });
213
+ const payload = (await response.json()) as CheckAnswerResult | { detail?: string };
214
+ if (!response.ok) {
215
+ throw new Error('detail' in payload && payload.detail ? payload.detail : 'Проверка не удалась.');
216
+ }
217
+
218
+ const result = payload as CheckAnswerResult;
219
+ setCheckResults((current) => ({ ...current, [task.id]: result }));
220
+
221
+ const attempt: TaskAttempt = {
222
+ task_id: task.id,
223
+ submitted_answer: draft,
224
+ is_correct: result.is_correct,
225
+ check_status: result.status_code,
226
+ created_at: new Date().toISOString(),
227
+ };
228
+
229
+ if (session && isSupabaseConfigured()) {
230
+ const { error: insertError } = await supabase.from('task_attempts').insert({
231
+ user_id: session.user.id,
232
+ task_id: task.id,
233
+ submitted_answer: draft,
234
+ is_correct: result.is_correct,
235
+ check_status: result.status_code,
236
+ });
237
+
238
+ if (!insertError) {
239
+ await fetchAttempts();
240
+ }
241
+ } else {
242
+ setLocalAttempts((current) => [attempt, ...current]);
243
+ }
244
+ } catch (err) {
245
+ setCheckResults((current) => ({
246
+ ...current,
247
+ [task.id]: {
248
+ success: false,
249
+ is_correct: false,
250
+ status_code: 'request_error',
251
+ status_label: 'Ошибка',
252
+ submitted_answer: draft,
253
+ normalized_answer: draft,
254
+ message: err instanceof Error ? err.message : 'Проверка не удалась.',
255
+ },
256
+ }));
257
+ } finally {
258
+ setCheckingTaskId(null);
259
+ }
260
+ };
261
+
262
+ if (loading) {
263
+ return (
264
+ <div className="flex justify-center py-20">
265
+ <Loader />
266
+ </div>
267
+ );
268
+ }
269
+
270
+ return (
271
+ <div className="mx-auto max-w-6xl space-y-6 pb-12 animate-fade-in">
272
+ <div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
273
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
274
+ <div>
275
+ <h2 className="text-3xl font-serif font-bold text-refined-dark">Практика по заданиям ФИПИ</h2>
276
+ <p className="mt-1 text-stone-500">
277
+ Задания подгружаются из базы автоматически. Проверка ответа доступна для задач из банка ФИПИ.
278
+ </p>
279
+ </div>
280
+ <div className="flex flex-col gap-3 sm:flex-row">
281
+ <input
282
+ value={search}
283
+ onChange={(event) => setSearch(event.target.value)}
284
+ placeholder="Поиск по тексту задания"
285
+ className="rounded-full border border-stone-300 px-4 py-3 text-sm text-stone-700 outline-none transition focus:border-refined-red"
286
+ />
287
+ <button
288
+ onClick={fetchTasks}
289
+ className="rounded-full bg-stone-100 px-5 py-3 text-sm font-medium text-stone-700 transition hover:bg-stone-200"
290
+ >
291
+ Обновить список
292
+ </button>
293
+ </div>
294
+ </div>
295
+
296
+ <div className="mt-5 flex flex-wrap gap-2">
297
+ {FILTERS.map((filter) => {
298
+ const count =
299
+ filter === 'all'
300
+ ? tasks.length
301
+ : filter === 'checkable'
302
+ ? tasks.filter((task) => task.can_check_answer).length
303
+ : tasks.filter((task) => task.task_type === filter).length;
304
+ if (count === 0 && filter !== 'all') {
305
+ return null;
306
+ }
307
+ return (
308
+ <button
309
+ key={filter}
310
+ onClick={() => setFilterType(filter)}
311
+ className={`rounded-full px-4 py-2 text-sm font-medium transition ${
312
+ filterType === filter
313
+ ? 'bg-refined-red text-white'
314
+ : 'bg-stone-100 text-stone-600 hover:bg-stone-200'
315
+ }`}
316
+ >
317
+ {filter === 'all' ? 'Все' : filter === 'checkable' ? 'С проверкой' : filter}
318
+ {' '}
319
+ ({count})
320
+ </button>
321
+ );
322
+ })}
323
+ </div>
324
+ </div>
325
+
326
+ <div className="grid gap-4 md:grid-cols-4">
327
+ <div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
328
+ <p className="text-xs font-bold uppercase text-stone-500">Всего заданий</p>
329
+ <p className="mt-2 text-3xl font-serif font-bold text-refined-dark">{tasks.length}</p>
330
+ </div>
331
+ <div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
332
+ <p className="text-xs font-bold uppercase text-stone-500">Решено</p>
333
+ <p className="mt-2 text-3xl font-serif font-bold text-refined-dark">{taskStats.solvedTasks}</p>
334
+ </div>
335
+ <div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
336
+ <p className="text-xs font-bold uppercase text-stone-500">Ошибок</p>
337
+ <p className="mt-2 text-3xl font-serif font-bold text-refined-dark">{taskStats.incorrectAttempts}</p>
338
+ </div>
339
+ <div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
340
+ <p className="text-xs font-bold uppercase text-stone-500">Точность</p>
341
+ <p className="mt-2 text-3xl font-serif font-bold text-refined-dark">{taskStats.accuracy}%</p>
342
+ </div>
343
+ </div>
344
+
345
+ {(statsLoading || taskStats.hardestTasks.length > 0) && (
346
+ <div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
347
+ <h3 className="font-serif text-xl font-bold text-refined-dark">Статистика ошибок</h3>
348
+ {statsLoading ? (
349
+ <div className="mt-4 flex justify-center">
350
+ <Loader />
351
+ </div>
352
+ ) : taskStats.hardestTasks.length === 0 ? (
353
+ <p className="mt-3 text-stone-500">Пока нет ошибок по сохранённым попыткам.</p>
354
+ ) : (
355
+ <div className="mt-4 space-y-3">
356
+ {taskStats.hardestTasks.map(({ task, wrongCount }) => (
357
+ <div key={task!.id} className="rounded-xl border border-stone-200 bg-stone-50 p-4">
358
+ <div className="flex items-start justify-between gap-4">
359
+ <div>
360
+ <p className="font-semibold text-refined-dark">{task!.title}</p>
361
+ <p className="mt-1 text-sm text-stone-500">{sourceLabel(task!)}</p>
362
+ </div>
363
+ <span className="rounded-full bg-red-100 px-3 py-1 text-xs font-medium text-red-700">
364
+ Ошибок: {wrongCount}
365
+ </span>
366
+ </div>
367
+ </div>
368
+ ))}
369
+ </div>
370
+ )}
371
+ </div>
372
+ )}
373
+
374
+ {error && (
375
+ <div className="rounded-2xl border border-red-200 bg-red-50 p-6 text-red-700">
376
+ {error}
377
+ </div>
378
+ )}
379
+
380
+ <div className="space-y-4">
381
+ {filteredTasks.map((task) => {
382
+ const latestAttempt = latestAttemptByTask.get(task.id);
383
+ const isExpanded = selectedTaskId === task.id;
384
+ const checkResult = checkResults[task.id];
385
+ return (
386
+ <div key={task.id} className="rounded-2xl border border-stone-200 bg-white shadow-sm">
387
+ <div className="flex flex-col gap-4 p-6 md:flex-row md:items-start md:justify-between">
388
+ <div className="min-w-0 flex-1">
389
+ <div className="flex flex-wrap items-center gap-2">
390
+ <span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-medium text-stone-600">
391
+ {sourceLabel(task)}
392
+ </span>
393
+ {task.can_check_answer && (
394
+ <span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700">
395
+ Можно проверить
396
+ </span>
397
+ )}
398
+ {latestAttempt && (
399
+ <span
400
+ className={`rounded-full px-3 py-1 text-xs font-medium ${
401
+ latestAttempt.is_correct
402
+ ? 'bg-emerald-100 text-emerald-700'
403
+ : 'bg-red-100 text-red-700'
404
+ }`}
405
+ >
406
+ {latestAttempt.is_correct ? 'Последний ответ верный' : 'Последний ответ неверный'}
407
+ </span>
408
+ )}
409
+ </div>
410
+ <h3 className="mt-3 text-xl font-semibold text-refined-dark">{task.title}</h3>
411
+ <p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm text-stone-600">{task.content}</p>
412
+ </div>
413
+ <button
414
+ onClick={() => setSelectedTaskId(isExpanded ? null : task.id)}
415
+ className="rounded-full bg-stone-100 px-4 py-2 text-sm font-medium text-stone-700 transition hover:bg-stone-200"
416
+ >
417
+ {isExpanded ? 'Скрыть' : 'Открыть'}
418
+ </button>
419
+ </div>
420
+
421
+ {isExpanded && (
422
+ <div className="border-t border-stone-200 px-6 py-6">
423
+ <div className="rounded-xl border border-stone-200 bg-refined-paper p-5">
424
+ <p className="whitespace-pre-wrap text-stone-700">{task.content}</p>
425
+
426
+ {task.variants && task.variants.length > 0 && (
427
+ <div className="mt-5">
428
+ <p className="mb-2 text-sm font-semibold text-refined-dark">Варианты из задания</p>
429
+ <div className="space-y-2">
430
+ {task.variants.map((variant, index) => (
431
+ <div key={`${task.id}-variant-${index}`} className="rounded-lg bg-white px-3 py-2 text-sm text-stone-700">
432
+ {variant}
433
+ </div>
434
+ ))}
435
+ </div>
436
+ </div>
437
+ )}
438
+
439
+ <div className="mt-6 space-y-3">
440
+ {task.can_check_answer ? (
441
+ <>
442
+ <label className="block text-sm font-semibold text-refined-dark">
443
+ Введите ответ
444
+ </label>
445
+ <div className="flex flex-col gap-3 md:flex-row">
446
+ <input
447
+ value={answerDrafts[task.id] || ''}
448
+ onChange={(event) =>
449
+ setAnswerDrafts((current) => ({
450
+ ...current,
451
+ [task.id]: event.target.value,
452
+ }))
453
+ }
454
+ placeholder="Например: 25 или СЛОВО"
455
+ className="flex-1 rounded-xl border border-stone-300 px-4 py-3 text-sm text-stone-700 outline-none transition focus:border-refined-red"
456
+ />
457
+ <button
458
+ onClick={() => submitAnswer(task)}
459
+ disabled={checkingTaskId === task.id}
460
+ className="rounded-xl bg-refined-red px-5 py-3 text-sm font-medium text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
461
+ >
462
+ {checkingTaskId === task.id ? 'Проверяю...' : 'Проверить'}
463
+ </button>
464
+ </div>
465
+ <p className="text-xs text-stone-500">
466
+ Для статистики попытки сохраняются в Supabase, если пользователь авторизован.
467
+ </p>
468
+ </>
469
+ ) : (
470
+ <div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
471
+ Для этой задачи автоматическая проверка пока недоступна. Проверка работает для задач из динамического банка ФИПИ.
472
+ </div>
473
+ )}
474
+
475
+ {checkResult && (
476
+ <div
477
+ className={`rounded-xl p-4 text-sm font-medium ${
478
+ checkResult.is_correct
479
+ ? 'bg-emerald-50 text-emerald-700'
480
+ : 'bg-red-50 text-red-700'
481
+ }`}
482
+ >
483
+ <p>{checkResult.message}</p>
484
+ <p className="mt-1 text-xs opacity-80">
485
+ Отправлено: {checkResult.submitted_answer}
486
+ </p>
487
+ </div>
488
+ )}
489
+ </div>
490
+
491
+ <a
492
+ href={task.source_url}
493
+ target="_blank"
494
+ rel="noopener noreferrer"
495
+ className="mt-5 inline-flex items-center gap-2 text-sm font-medium text-refined-red hover:underline"
496
+ >
497
+ Открыть источник
498
+ </a>
499
+ </div>
500
+ </div>
501
+ )}
502
+ </div>
503
+ );
504
+ })}
505
+ </div>
506
+
507
+ {!error && filteredTasks.length === 0 && (
508
+ <div className="rounded-2xl border border-stone-200 bg-white p-10 text-center text-stone-500 shadow-sm">
509
+ По текущему фильтру заданий не найдено.
510
+ </div>
511
+ )}
512
+ </div>
513
+ );
514
+ };
515
+
516
+ export default TasksTab;