diff --git a/src/App.tsx b/src/App.tsx index 91f589b4..3c8e766a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import Dashboard from './components/dashboard/Dashboard'; import Messages from './components/messages/Messages'; import Courses from './components/courses/Courses'; import AttendanceOverview from './components/courses/AttendanceOverview'; +import Submissions from './components/submissions/Submissions'; import Kalender from './components/calendar/Kalender'; import Profile from './components/profile/Profile'; import Settings from './components/settings/Settings'; @@ -81,6 +82,8 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -118,6 +121,8 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/courses/Courses.tsx b/src/components/courses/Courses.tsx index 6d8e4856..021418d2 100644 --- a/src/components/courses/Courses.tsx +++ b/src/components/courses/Courses.tsx @@ -13,12 +13,11 @@ import { CourseDetails, CourseMark, CourseDetailEntry, + EntryUpload, EntryDetailsResponse, EntryDetails, WeeklyViewResponse, WeeklyEntry, - SubmissionsResponse, - Submission } from '../../types'; import { AcademicCapIcon, @@ -36,13 +35,14 @@ import { ChartBarIcon, ListBulletIcon, Squares2X2Icon, + ArrowUpTrayIcon, } from '@heroicons/react/24/outline'; import { format, parseISO } from 'date-fns'; import { de } from 'date-fns/locale'; import clsx from 'clsx'; import { isDemoRoute } from '../../utils/demoMode'; -type ViewMode = 'overview' | 'course-detail' | 'weekly' | 'submissions' | 'entry-detail'; +type ViewMode = 'overview' | 'course-detail' | 'weekly' | 'entry-detail'; type CourseDetailTab = 'history' | 'performance' | 'exams'; const courseDetailTabs: Array<{ @@ -67,6 +67,34 @@ const EmptyCourseTab: React.FC<{ ); +const UploadButtons: React.FC<{ + uploads?: EntryUpload[]; + onOpen: (upload: EntryUpload) => void; +}> = ({ uploads = [], onOpen }) => { + if (uploads.length === 0) return null; + return ( +
+ {uploads.map((upload) => ( + + ))} +
+ ); +}; + const CoursePerformance: React.FC<{ marks?: CourseMark[] }> = ({ marks = [] }) => { if (marks.length === 0) { return ( @@ -265,7 +293,6 @@ const Courses: React.FC = () => { const [selectedCourse, setSelectedCourse] = useState(null); const [selectedEntry, setSelectedEntry] = useState(null); const [weeklyEntries, setWeeklyEntries] = useState([]); - const [submissions, setSubmissions] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); const [filterAttendance, setFilterAttendance] = useState('all'); @@ -429,28 +456,6 @@ const Courses: React.FC = () => { } }; - const loadSubmissions = async () => { - if (!token) return; - - try { - setIsLoading(true); - setError(''); - const response = await coursesAPI.getSubmissions(token); - - if (response.success) { - setSubmissions(response.submissions); - setViewMode('submissions'); - } else { - setError('Fehler beim Laden der Abgaben.'); - } - setIsLoading(false); - } catch (error) { - console.error('Error loading submissions:', error); - setError('Fehler beim Laden der Abgaben.'); - setIsLoading(false); - } - }; - const toggleHomework = async (courseId: string, entryId: string, currentDone: boolean) => { if (!token) return; const newDone = !currentDone; @@ -517,27 +522,15 @@ const Courses: React.FC = () => { const goBack = () => { if (viewMode === 'entry-detail') { setViewMode('course-detail'); - } else if (viewMode === 'submissions' || viewMode === 'weekly') { + } else if (viewMode === 'weekly') { setViewMode('overview'); } else { navigate(`${basePath}/courses`); } }; - const getSubmissionStatusColor = (status: string) => { - switch (status.toLowerCase()) { - case 'submitted': - case 'abgegeben': - return 'text-green-600 bg-green-100'; - case 'overdue': - case 'überfällig': - return 'text-red-600 bg-red-100'; - case 'pending': - case 'ausstehend': - return 'text-yellow-600 bg-yellow-100'; - default: - return 'text-surface-600 dark:text-surface-400 bg-surface-200 dark:bg-surface-700'; - } + const openUpload = (upload: EntryUpload) => { + navigate(`${basePath}/courses/submissions/${encodeURIComponent(upload.detail_ref)}`); }; // Show course detail skeleton when navigating to a course @@ -618,7 +611,6 @@ const Courses: React.FC = () => { {viewMode === 'overview' && 'Mein Unterricht'} {viewMode === 'course-detail' && selectedCourseName} {viewMode === 'weekly' && 'Wochenansicht'} - {viewMode === 'submissions' && 'Abgaben'} {viewMode === 'entry-detail' && selectedEntry?.title} {viewMode === 'course-detail' && selectedCourse && ( @@ -630,7 +622,7 @@ const Courses: React.FC = () => { {viewMode === 'overview' && (
)} + ))} @@ -1147,6 +1140,7 @@ const Courses: React.FC = () => { ))} )} + ))} @@ -1263,69 +1257,6 @@ const Courses: React.FC = () => { )} - {viewMode === 'submissions' && isLoading && ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
- ))} -
- )} - - {viewMode === 'submissions' && !isLoading && ( -
- {!submissions || submissions.length === 0 ? ( -
- -

Keine Abgaben

-

- Derzeit sind keine Abgaben fällig. -

-
- ) : ( -
- {submissions.map((submission) => ( -
-
-
-
-

{submission.title}

- - {submission.status} - -
-

{submission.course}

-
- - Fällig: {formatDateTime(submission.due_date)} -
-
- - Öffnen - -
-
- ))} -
- )} -
- )}
); }; diff --git a/src/components/demo/mockApi.ts b/src/components/demo/mockApi.ts index eb442c76..a630be2b 100644 --- a/src/components/demo/mockApi.ts +++ b/src/components/demo/mockApi.ts @@ -192,14 +192,61 @@ const mockCourses = [ ]; const mockSubmissions = [ - { id: 's1', title: 'Mathematik: Funktionsgraphen', course: 'Mathematik 9c', due_date: relDateTime(1, '23:59:00'), status: 'Ausstehend', url: '' }, - { id: 's2', title: 'Deutsch: Gedichtvergleich', course: 'Deutsch 9c', due_date: relDateTime(3, '09:40:00'), status: 'Anstehend', url: '' }, - { id: 's3', title: 'Biologie: See-Experiment', course: 'Biologie 9c', due_date: relDateTime(-1, '23:59:00'), status: 'Abgegeben', url: '' }, - { id: 's4', title: 'Englisch: Persuasive speech', course: 'Englisch 9c', due_date: relDateTime(4, '23:59:00'), status: 'Ausstehend', url: '' }, - { id: 's5', title: 'Geschichte: Fabrikarbeit im 19. Jahrhundert', course: 'Geschichte 9c', due_date: relDateTime(8, '23:59:00'), status: 'Ausstehend', url: '' }, - { id: 's6', title: 'Informatik: Barrierefreie Website', course: 'Informatik 9c', due_date: relDateTime(12, '09:40:00'), status: 'Anstehend', url: '' }, + { id: 's1', detail_ref: 'demo-s1', course_id: 'b2', entry_id: 'b2e1', title: 'Funktionsgraphen', course_name: 'Mathematik 9c', date_text: 'Freitag, 25.09.2026 · 23:59 Uhr', status: 'open', uploaded_count: 0 }, + { id: 's2', detail_ref: 'demo-s2', course_id: 'b1', entry_id: 'b1e1', title: 'Gedichtvergleich', course_name: 'Deutsch 9c', date_text: 'Montag, 28.09.2026 · 09:40 Uhr', status: 'open', uploaded_count: 1 }, + { id: 's3', detail_ref: 'demo-s3', course_id: 'b4', entry_id: 'b4e1', title: 'See-Experiment', course_name: 'Biologie 9c', date_text: 'Abgabe geschlossen', status: 'closed', uploaded_count: 2 }, + { id: 's4', detail_ref: 'demo-s4', course_id: 'b3', entry_id: 'b3e1', title: 'Persuasive speech', course_name: 'Englisch 9c', date_text: 'Mittwoch, 30.09.2026 · 23:59 Uhr', status: 'open', uploaded_count: 0 }, + { id: 's5', detail_ref: 'demo-s5', course_id: 'b5', entry_id: 'b5e1', title: 'Fabrikarbeit im 19. Jahrhundert', course_name: 'Geschichte 9c', date_text: 'Freitag, 02.10.2026 · 23:59 Uhr', status: 'open', uploaded_count: 0 }, + { id: 's6', detail_ref: 'demo-s6', course_id: 'b6', entry_id: 'b6e1', title: 'Barrierefreie Website', course_name: 'Informatik 9c', date_text: 'Montag, 05.10.2026 · 09:40 Uhr', status: 'open', uploaded_count: 0 }, ]; +type MockSubmission = (typeof mockSubmissions)[number]; +type MockSubmissionFile = { + name: string; + index: string; + time: string; + comment: null; + person: null; + download_ref: string; + public: false; +}; + +const mockSubmissionFiles: Record = {}; + +const createMockSubmissionFile = (submission: MockSubmission, index: number, name?: string): MockSubmissionFile => ({ + name: name || `${submission.course_name.replace(/\s+/g, '-')}-${index}.pdf`, + index: String(index), + time: 'Heute, 12:00 Uhr', + comment: null, + person: null, + download_ref: `demo-file-${submission.id}-${index}`, + public: false, +}); + +const getMockSubmissionFiles = (submission: MockSubmission): MockSubmissionFile[] => { + if (!mockSubmissionFiles[submission.id]) { + mockSubmissionFiles[submission.id] = Array.from( + { length: submission.uploaded_count }, + (_, index) => createMockSubmissionFile(submission, index + 1), + ); + } + submission.uploaded_count = mockSubmissionFiles[submission.id].length; + return mockSubmissionFiles[submission.id]; +}; + +const mockFormValue = (data: any, name: string): string => { + const value = data?.get?.(name) ?? data?.[name]; + return typeof value === 'string' ? value : ''; +}; + +const mockUploadedNames = (data: any): string[] => { + const files = data?.getAll?.('files'); + if (!Array.isArray(files)) return []; + return files + .map(file => (typeof file?.name === 'string' ? file.name : '')) + .filter(Boolean); +}; + const mockAttendanceOverview = { success: true, source: 'schulportal', @@ -225,7 +272,7 @@ const mockCourseDetails: Record = { b1: { course_id: 'b1', course_name: 'Deutsch 9c', semester: '1. Halbjahr 2026/2027', teacher_short: 'CN', teacher_full: 'Clara Neumann', entries: [ - { entry_id: 'b1e1', date: daysAgo(1), hours: '3–4', thema: 'Gedichtvergleich: Stadt und Natur', homework: 'Vergleichstabelle zu den beiden Gedichten vervollständigen', homework_done: false, attendance: 'anwesend', files: [{ name: 'Gedichtvergleich-Leitfaden.pdf', url: '/files/gedichtvergleich-leitfaden.pdf' }], content: 'Wir vergleichen Bildsprache, Rhythmus und die Perspektive der beiden Gedichte. Zum Schluss begründen wir, wie die Sprache die jeweilige Stimmung erzeugt.' }, + { entry_id: 'b1e1', date: daysAgo(1), hours: '3–4', thema: 'Gedichtvergleich: Stadt und Natur', homework: 'Vergleichstabelle zu den beiden Gedichten vervollständigen', homework_done: false, attendance: 'anwesend', files: [{ name: 'Gedichtvergleich-Leitfaden.pdf', url: '/files/gedichtvergleich-leitfaden.pdf' }], uploads: [{ id: 'demo-s2', detail_ref: 'demo-s2', title: 'Gedichtvergleich', status: 'open', uploaded_count: 1 }], content: 'Wir vergleichen Bildsprache, Rhythmus und die Perspektive der beiden Gedichte. Zum Schluss begründen wir, wie die Sprache die jeweilige Stimmung erzeugt.' }, { entry_id: 'b1e2', date: daysAgo(5), hours: '1–2', thema: 'Sprachliche Bilder und Wirkung', homework: 'Drei Metaphern aus dem Text erklären', homework_done: true, attendance: 'anwesend', files: [], content: 'Wir unterscheiden Metapher, Vergleich und Personifikation und untersuchen ihre Wirkung im Gedicht.' }, { entry_id: 'b1e3', date: daysAgo(9), hours: '3–4', thema: 'Eine Textdeutung strukturieren', homework: '', homework_done: true, attendance: 'anwesend', files: [], content: 'Wir haben eine Deutungshypothese formuliert und die passenden Belege im Text geordnet.' }, ], @@ -240,7 +287,7 @@ const mockCourseDetails: Record = { b2: { course_id: 'b2', course_name: 'Mathematik 9c', semester: '1. Halbjahr 2026/2027', teacher_short: 'MV', teacher_full: 'Martin Vogel', entries: [ - { entry_id: 'b2e1', date: daysAgo(2), hours: '1–2', thema: 'Lineare Funktionen und Steigung', homework: 'Arbeitsblatt „Funktionsgraphen“: Nr. 4–7', homework_done: false, attendance: 'anwesend', files: [{ name: 'Funktionsgraphen-Arbeitsblatt.pdf', url: '/files/funktionsgraphen-arbeitsblatt.pdf' }], content: 'Wir lesen Steigung und y-Achsenabschnitt aus verschiedenen Darstellungen ab und zeichnen den passenden Graphen.' }, + { entry_id: 'b2e1', date: daysAgo(2), hours: '1–2', thema: 'Lineare Funktionen und Steigung', homework: 'Arbeitsblatt „Funktionsgraphen“: Nr. 4–7', homework_done: false, attendance: 'anwesend', files: [{ name: 'Funktionsgraphen-Arbeitsblatt.pdf', url: '/files/funktionsgraphen-arbeitsblatt.pdf' }], uploads: [{ id: 'demo-s1', detail_ref: 'demo-s1', title: 'Funktionsgraphen', status: 'open', uploaded_count: 0 }], content: 'Wir lesen Steigung und y-Achsenabschnitt aus verschiedenen Darstellungen ab und zeichnen den passenden Graphen.' }, { entry_id: 'b2e2', date: daysAgo(6), hours: '3–4', thema: 'Tabellen, Graphen und Terme', homework: 'Drei Darstellungen derselben Funktion zuordnen', homework_done: true, attendance: 'anwesend', files: [], content: 'Wir übertragen Werte aus einer Tabelle in ein Koordinatensystem und prüfen unsere Ergebnisse mit dem Funktionsterm.' }, { entry_id: 'b2e3', date: daysAgo(10), hours: '1–2', thema: 'Koordinatensysteme sicher nutzen', homework: '', homework_done: true, attendance: 'anwesend', files: [], content: 'Wiederholung von Punkten, Achsenbeschriftung und sinnvollen Maßstäben.' }, ], @@ -292,6 +339,17 @@ const mockCourseDetails: Record = { }, }; +const syncMockCourseSubmissionCount = (submission: MockSubmission) => { + const course = mockCourseDetails[submission.course_id]; + const entry = course?.entries?.find( + (item: any) => item.entry_id === submission.entry_id, + ); + const upload = entry?.uploads?.find( + (item: any) => (item.detail_ref || item.id) === submission.detail_ref, + ); + if (upload) upload.uploaded_count = submission.uploaded_count; +}; + const mockEntryDetails: Record = { b1e1: { id: 'b1e1', title: 'Gedichtvergleich: Stadt und Natur', content: '

Vergleiche Bildsprache und Stimmung der beiden Gedichte.

Arbeitsauftrag: Belege deine Aussage mit je einer Textstelle und einem Fachbegriff.

', date: daysAgo(1), attachments: [{ name: 'Gedichtvergleich-Leitfaden.pdf', url: '/files/gedichtvergleich-leitfaden.pdf' }] }, b2e1: { id: 'b2e1', title: 'Lineare Funktionen und Steigung', content: '

Lies Steigung und y-Achsenabschnitt aus dem Graphen ab und zeichne die Funktion.

Arbeitsauftrag: Notiere jeden Rechenschritt und prüfe einen Punkt durch Einsetzen.

', date: daysAgo(2), attachments: [{ name: 'Funktionsgraphen-Arbeitsblatt.pdf', url: '/files/funktionsgraphen-arbeitsblatt.pdf' }] }, @@ -704,6 +762,57 @@ export function getMockResponse(url: string, method: string, config: any): { dat { date: weekDate(4), course: 'Informatik 9c', entry: 'Barrierefreie Website', url: '' }, ] } } }; } + if (u.startsWith('/meinunterricht/submissions/file/') && method === 'get') { + return { status: 200, data: new Blob(['Demo-Abgabedatei'], { type: 'text/plain' }) }; + } + if (u === '/meinunterricht/submissions/upload' && method === 'post') { + const uploadId = mockFormValue(config?.data, 'upload_id'); + const summary = mockSubmissions.find(item => `upload-${item.id}` === uploadId) || mockSubmissions[0]; + const files = getMockSubmissionFiles(summary); + const names = mockUploadedNames(config?.data); + const uploadedNames = names.length > 0 ? names : ['demo-upload.pdf']; + const statuses = uploadedNames.map(name => { + const nextIndex = files.reduce((highest, file) => Math.max(highest, Number(file.index) || 0), 0) + 1; + files.push(createMockSubmissionFile(summary, nextIndex, name)); + return { name, status: 'erfolgreich', message: null }; + }); + summary.uploaded_count = files.length; + syncMockCourseSubmissionCount(summary); + return { status: 200, data: { success: true, all_succeeded: true, files: statuses } }; + } + if (u === '/meinunterricht/submissions/file' && method === 'delete') { + const uploadId = mockFormValue(config?.data, 'upload_id'); + const summary = mockSubmissions.find(item => `upload-${item.id}` === uploadId) || mockSubmissions[0]; + const files = getMockSubmissionFiles(summary); + const fileIndex = mockFormValue(config?.data, 'file_index'); + const index = files.findIndex(file => file.index === fileIndex); + if (index >= 0) files.splice(index, 1); + summary.uploaded_count = files.length; + syncMockCourseSubmissionCount(summary); + return { status: 200, data: { success: true, code: '1', message: 'File deleted successfully' } }; + } + if (u.startsWith('/meinunterricht/submissions/') && method === 'get') { + const ref = u.split('/').pop() || 'demo-s1'; + const summary = mockSubmissions.find(item => item.detail_ref === ref) || mockSubmissions[0]; + const ownFiles = getMockSubmissionFiles(summary); + return { status: 200, data: { success: true, submission: { + ...summary, + upload_id: `upload-${summary.id}`, + start: 'Montag, 21.09.2026 · 08:00 Uhr', + deadline: summary.date_text, + automatic_deletion: '30.09.2026', + allows_multiple_files: true, + allows_multiple_attempts: true, + visibility: 'Nur Lehrkräfte', + allowed_file_types: ['PDF', 'DOCX'], + max_file_size: '10 MB', + additional_text: 'Bitte nur die fertige Datei abgeben.', + own_files: ownFiles, + public_files: [{ name: 'Hinweise.pdf', index: '99', time: null, comment: null, person: 'Frau Vogel', download_ref: 'demo-public-file', public: true }], + can_upload: summary.status === 'open', + can_delete: ownFiles.length > 0, + } } }; + } if (u === '/meinunterricht/submissions' && method === 'get') { return { status: 200, data: { success: true, submissions: mockSubmissions } }; } if (u === '/meinunterricht/homework-done' && method === 'post') { return { status: 200, data: { success: true } }; } diff --git a/src/components/submissions/Submissions.tsx b/src/components/submissions/Submissions.tsx new file mode 100644 index 00000000..29fe9183 --- /dev/null +++ b/src/components/submissions/Submissions.tsx @@ -0,0 +1,443 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + ArrowDownTrayIcon, + ArrowLeftIcon, + ArrowPathIcon, + CheckCircleIcon, + ClockIcon, + DocumentArrowUpIcon, + DocumentTextIcon, + ExclamationCircleIcon, + InformationCircleIcon, + LockClosedIcon, + TrashIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import clsx from 'clsx'; +import axios from 'axios'; +import { useAuth } from '../../contexts/AuthContext'; +import { useBasePath } from '../../contexts/BasePathContext'; +import { coursesAPI } from '../../services/api'; +import { Submission, SubmissionDetail, SubmissionUploadStatus } from '../../types'; + +const statusLabel = (status: string) => status === 'open' ? 'Offen' : 'Geschlossen'; + +const statusClasses = (status: string) => status === 'open' + ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-200' + : 'bg-surface-200 text-surface-700 dark:bg-surface-800 dark:text-surface-300'; + +const parseMaxBytes = (value?: string | null) => { + if (!value) return null; + const match = value.replace(',', '.').match(/([\d.]+)\s*(KB|MB|GB)/i); + if (!match) return null; + const amount = Number(match[1]); + const multiplier = match[2].toUpperCase() === 'GB' + ? 1_000_000_000 + : match[2].toUpperCase() === 'MB' ? 1_000_000 : 1_000; + return Number.isFinite(amount) ? amount * multiplier : null; +}; + +const formatBytes = (bytes: number) => { + if (bytes < 1_000_000) return `${Math.round(bytes / 1_000)} KB`; + return `${(bytes / 1_000_000).toFixed(bytes >= 10_000_000 ? 0 : 1)} MB`; +}; + +const extensionOf = (filename: string) => filename.split('.').pop()?.toLowerCase() || ''; + +const allowedExtension = (filename: string, allowed: string[]) => { + if (!allowed.length || allowed.some(value => value.trim().toLowerCase() === 'alle')) return true; + const extension = extensionOf(filename); + return allowed.some(value => value.trim().replace(/^\./, '').toLowerCase() === extension); +}; + +const SubmissionStatus: React.FC<{ status: string }> = ({ status }) => ( + + {status === 'open' ? : } + {statusLabel(status)} + +); + +const SubmissionCard: React.FC<{ + submission: Submission; + onOpen: () => void; +}> = ({ submission, onOpen }) => ( + +); + +const FileRow: React.FC<{ + name: string; + meta?: string | null; + action?: React.ReactNode; +}> = ({ name, meta, action }) => ( +
+
+ +
+
+

{name}

+ {meta &&

{meta}

} +
+ {action} +
+); + +const UploadResults: React.FC<{ statuses: SubmissionUploadStatus[] }> = ({ statuses }) => ( +
+

Upload-Ergebnis

+ {statuses.map((item, index) => ( +
+ {item.status === 'erfolgreich' + ? + : } + + {item.name} · {item.status} + {item.message && — {item.message}} + +
+ ))} +
+); + +const SubmissionDetailView: React.FC<{ + detail: SubmissionDetail; + refreshError: string; + token: string; + onBack: () => void; + onRefresh: () => Promise; +}> = ({ detail, refreshError, token, onBack, onRefresh }) => { + const inputRef = useRef(null); + const mountedRef = useRef(true); + const [selectedFiles, setSelectedFiles] = useState([]); + const [busy, setBusy] = useState(false); + const [downloading, setDownloading] = useState(null); + const [error, setError] = useState(''); + const [uploadStatuses, setUploadStatuses] = useState([]); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteError, setDeleteError] = useState(''); + const [password, setPassword] = useState(''); + + useEffect(() => () => { + mountedRef.current = false; + }, []); + + const maxBytes = parseMaxBytes(detail.max_file_size); + const attemptClosed = detail.allows_multiple_attempts === false && detail.own_files.length > 0; + const uploadAllowed = detail.status === 'open' && detail.can_upload && !attemptClosed; + const multipleFilesAllowed = detail.allows_multiple_files !== false; + const accept = detail.allowed_file_types.length > 0 + && !detail.allowed_file_types.some(type => type.trim().toLowerCase() === 'alle') + ? detail.allowed_file_types + .map(type => `.${type.toLowerCase().replace(/^\./, '')}`) + .join(',') + : undefined; + + const chooseFiles = (event: React.ChangeEvent) => { + const files = Array.from(event.target.files || []); + setError(''); + if (!multipleFilesAllowed && files.length > 1) { + setError('Für diese Abgabe ist nur eine Datei erlaubt.'); + event.target.value = ''; + return; + } + const invalidType = files.find(file => !allowedExtension(file.name, detail.allowed_file_types)); + if (invalidType) { + setError(`${invalidType.name} hat keinen erlaubten Dateityp.`); + event.target.value = ''; + return; + } + if (maxBytes !== null) { + const oversized = files.find(file => file.size > maxBytes); + if (oversized) { + setError(`${oversized.name} ist mit ${formatBytes(oversized.size)} größer als erlaubt (${detail.max_file_size}).`); + event.target.value = ''; + return; + } + } + setSelectedFiles(files); + }; + + const upload = async () => { + if (!selectedFiles.length || busy) return; + setBusy(true); + setError(''); + setUploadStatuses([]); + try { + const response = await coursesAPI.uploadSubmissionFiles(token, detail, selectedFiles); + if (!response.success) { + setError(response.error || 'Die Dateien konnten nicht hochgeladen werden.'); + } else { + setUploadStatuses(response.files || []); + setSelectedFiles([]); + if (inputRef.current) inputRef.current.value = ''; + if (mountedRef.current) await onRefresh(); + } + } catch (uploadError) { + if (!axios.isCancel(uploadError)) setError('Die Dateien konnten nicht hochgeladen werden.'); + } finally { + setBusy(false); + } + }; + + const download = async (fileRef: string, name: string) => { + if (downloading) return; + setDownloading(fileRef); + setError(''); + try { + const blob = await coursesAPI.downloadSubmissionFile(token, fileRef); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = name; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(objectUrl); + } catch { + setError('Die Datei konnte nicht heruntergeladen werden.'); + } finally { + setDownloading(null); + } + }; + + const removeFile = async () => { + if (!deleteTarget || !password || busy) return; + setBusy(true); + setError(''); + setDeleteError(''); + try { + const response = await coursesAPI.deleteSubmissionFile(token, detail, deleteTarget, password); + if (!response.success) { + setDeleteError(response.message || response.error || 'Die Datei konnte nicht gelöscht werden.'); + } else { + setDeleteTarget(null); + setPassword(''); + setDeleteError(''); + if (mountedRef.current) await onRefresh(); + } + } catch { + setDeleteError('Die Datei konnte nicht gelöscht werden.'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+ +
+

{detail.course_name || 'Kurs'}

+

{detail.title}

+
+
+ +
+ + {(error || refreshError) &&
{error || refreshError}
} + +
+
+
+
+

Abgabe

Deine Dateien

+ +
+
+
+ {detail.own_files.length === 0 &&
Du hast noch keine Datei abgegeben.
} + {detail.own_files.map(file => ( + {detail.can_delete && }
} + /> + ))} + + {uploadAllowed ? ( +
+ + + {selectedFiles.length > 0 &&
{selectedFiles.map(file => { setSelectedFiles(current => current.filter(item => item !== file)); if (inputRef.current) inputRef.current.value = ''; }} className="rounded-lg p-2 text-surface-500 hover:bg-surface-200 dark:hover:bg-surface-800" aria-label={`${file.name} entfernen`}>} />)}
} + +
+ ) : ( +
{attemptClosed ? 'Diese Abgabe erlaubt keine weiteren Versuche.' : detail.status === 'closed' ? 'Die Abgabe ist geschlossen.' : 'Das Hochladen ist derzeit nicht möglich.'}
+ )} + {uploadStatuses.length > 0 && } +
+ + + +
+ + {deleteTarget &&

Datei löschen?

Das Schulportal verlangt dein Passwort, um diese Datei endgültig zu löschen.

{deleteError &&
{deleteError}
}
} +
+ ); +}; + +const Submissions: React.FC = () => { + const { token } = useAuth(); + const basePath = useBasePath(); + const navigate = useNavigate(); + const { id } = useParams<{ id?: string }>(); + const [submissions, setSubmissions] = useState([]); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const detailRequestRef = useRef(0); + const currentIdRef = useRef(id); + currentIdRef.current = id; + + const listPath = `${basePath}/courses/submissions`; + const openSubmission = (submission: Submission) => navigate(`${listPath}/${encodeURIComponent(submission.detail_ref || submission.id)}`); + + const loadList = async (signal?: AbortSignal) => { + if (!token) return; + setLoading(true); + setError(''); + try { + const response = await coursesAPI.getSubmissions(token, signal); + if (response.success) setSubmissions(response.submissions || []); + else setError(response.error || 'Die Abgaben konnten nicht geladen werden.'); + } catch (loadError) { + if (!axios.isCancel(loadError)) setError('Die Abgaben konnten nicht geladen werden.'); + } finally { + if (!signal?.aborted) setLoading(false); + } + }; + + const loadDetail = async (detailRef: string, signal?: AbortSignal) => { + if (!token) return; + const requestId = detailRequestRef.current + 1; + detailRequestRef.current = requestId; + setLoading(true); + setError(''); + try { + const response = await coursesAPI.getSubmission(token, detailRef, signal); + if (requestId !== detailRequestRef.current || signal?.aborted) return; + if (response.success && response.submission) setDetail(response.submission); + else setError(response.error || 'Die Abgabe konnte nicht geladen werden.'); + } catch (loadError) { + if (requestId === detailRequestRef.current && !axios.isCancel(loadError)) { + setError('Die Abgabe konnte nicht geladen werden.'); + } + } finally { + if (requestId === detailRequestRef.current && !signal?.aborted) setLoading(false); + } + }; + + useEffect(() => { + const controller = new AbortController(); + setDetail(null); + if (id) void loadDetail(id, controller.signal); + else { + detailRequestRef.current += 1; + void loadList(controller.signal); + } + return () => controller.abort(); + }, [token, id]); + + const openCount = useMemo(() => submissions.filter(item => item.status === 'open').length, [submissions]); + const isDetail = Boolean(id); + + if (!token) return
Nicht authentifiziert
; + + return ( +
+ {isDetail && detail ? ( + navigate(listPath)} onRefresh={async () => { if (id && currentIdRef.current === id) await loadDetail(id); }} /> + ) : !isDetail ? ( +
+
+
+ +
+

Abgaben

+

Upload-Aufträge und Fristen

+
+
+ +
+ {error &&
{error}
} + {!loading && !error &&

Aufträge

{submissions.length}

Offen

{openCount}

Abgegeben

{submissions.filter(item => (item.uploaded_count || 0) > 0).length}

} + {loading ?
{Array.from({ length: 4 }).map((_, index) =>
)}
: submissions.length === 0 ? !error &&

Keine Abgaben

Sobald dir ein Upload-Auftrag zugewiesen wurde, erscheint er hier.

:
{submissions.map(submission => openSubmission(submission)} />)}
} +
+ ) : loading ? ( +
+ ) : ( +
{error || 'Die Abgabe konnte nicht geladen werden.'}
+ )} +
+ ); +}; + +export default Submissions; diff --git a/src/services/api.ts b/src/services/api.ts index 19b85532..8162c4b6 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -166,6 +166,9 @@ import { EntryDetailsResponse, WeeklyViewResponse, SubmissionsResponse, + SubmissionDetailResponse, + SubmissionUploadResponse, + SubmissionDeleteResponse, HealthResponse, CalendarOverviewResponse, CalendarEventsResponse, @@ -660,6 +663,74 @@ export const coursesAPI = { return response.data; }, + async getSubmission(token: string, detailRef: string, signal?: AbortSignal): Promise { + const response = await apiClient.get( + `/meinunterricht/submissions/${encodeURIComponent(detailRef)}`, + { headers: { 'X-Session-Token': token }, signal }, + ); + return response.data; + }, + + async uploadSubmissionFiles( + token: string, + detail: { course_id: string; entry_id: string; upload_id: string }, + files: File[], + signal?: AbortSignal, + ): Promise { + const form = new FormData(); + form.append('course_id', detail.course_id); + form.append('entry_id', detail.entry_id); + form.append('upload_id', detail.upload_id); + files.forEach(file => form.append('files', file, file.name)); + const response = await apiClient.post( + '/meinunterricht/submissions/upload', + form, + { + headers: { + 'X-Session-Token': token, + 'Content-Type': 'multipart/form-data', + }, + signal, + }, + ); + return response.data; + }, + + async deleteSubmissionFile( + token: string, + detail: { course_id: string; entry_id: string; upload_id: string }, + fileIndex: string, + password: string, + signal?: AbortSignal, + ): Promise { + const form = new FormData(); + form.append('course_id', detail.course_id); + form.append('entry_id', detail.entry_id); + form.append('upload_id', detail.upload_id); + form.append('file_index', fileIndex); + form.append('password', password); + const response = await apiClient.delete( + '/meinunterricht/submissions/file', + { + headers: { + 'X-Session-Token': token, + 'Content-Type': 'multipart/form-data', + }, + data: form, + signal, + }, + ); + return response.data; + }, + + async downloadSubmissionFile(token: string, fileRef: string, signal?: AbortSignal): Promise { + const response = await apiClient.get( + `/meinunterricht/submissions/file/${encodeURIComponent(fileRef)}`, + { headers: { 'X-Session-Token': token }, responseType: 'blob', signal }, + ); + return response.data; + }, + async toggleHomework(token: string, courseId: string, entryId: string, done: boolean, signal?: AbortSignal): Promise<{ success: boolean }> { const params = new URLSearchParams(); params.append('course_id', courseId); diff --git a/src/types/index.ts b/src/types/index.ts index 5f6514d5..21dcac75 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -492,9 +492,22 @@ export interface CourseDetailEntry { homework_done: boolean; attendance: string; files: EntryAttachment[]; + uploads?: EntryUpload[]; content?: string; } +export interface EntryUpload { + id: string; + detail_ref: string; + title: string; + status: 'open' | 'closed' | string; + date_text?: string | null; + uploaded?: string | null; + uploaded_count?: number | null; + course_id?: string; + entry_id?: string; +} + export interface CourseMark { name: string; date: string; @@ -567,17 +580,74 @@ export interface WeeklyViewResponse { export interface Submission { id: string; + detail_ref: string; + course_id: string; + entry_id: string; title: string; - course: string; - due_date: string; - status: string; - url: string; - [key: string]: any; + course_name: string; + status: 'open' | 'closed' | string; + date_text?: string | null; + uploaded?: string | null; + uploaded_count?: number | null; } export interface SubmissionsResponse { success: boolean; submissions: Submission[]; + error?: string; +} + +export interface SubmissionFile { + name: string; + index: string; + time?: string | null; + comment?: string | null; + person?: string | null; + download_ref: string; + public?: boolean; +} + +export interface SubmissionDetail extends Submission { + upload_id: string; + start?: string | null; + deadline?: string | null; + automatic_deletion?: string | null; + allows_multiple_files?: boolean | null; + allows_multiple_attempts?: boolean | null; + visibility?: string | null; + allowed_file_types: string[]; + max_file_size?: string | null; + additional_text?: string | null; + own_files: SubmissionFile[]; + public_files: SubmissionFile[]; + can_upload: boolean; + can_delete: boolean; +} + +export interface SubmissionDetailResponse { + success: boolean; + submission?: SubmissionDetail; + error?: string; +} + +export interface SubmissionUploadStatus { + name: string; + status: string; + message?: string | null; +} + +export interface SubmissionUploadResponse { + success: boolean; + files: SubmissionUploadStatus[]; + all_succeeded?: boolean; + error?: string; +} + +export interface SubmissionDeleteResponse { + success: boolean; + code?: string; + message?: string; + error?: string; } // Common types