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) => (
+
onOpen(upload)}
+ className={clsx(
+ 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
+ upload.status === 'open'
+ ? 'border-primary-200 bg-primary-50 text-primary-800 hover:bg-primary-100 dark:border-primary-900/70 dark:bg-primary-950/30 dark:text-primary-200 dark:hover:bg-primary-950/60'
+ : 'border-surface-200 bg-surface-100 text-surface-600 hover:bg-surface-200 dark:border-surface-700 dark:bg-surface-800 dark:text-surface-300 dark:hover:bg-surface-700',
+ )}
+ >
+ {upload.status === 'open' ? : }
+ {upload.title}
+ {upload.uploaded_count !== null && upload.uploaded_count !== undefined && · {upload.uploaded_count} }
+
+ ))}
+
+ );
+};
+
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' && (
navigate(`${basePath}/courses/submissions`)}
className="btn btn-secondary w-full sm:w-auto"
>
@@ -1045,6 +1037,7 @@ const Courses: React.FC = () => {
)}
+
))}
@@ -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 }) => (
+
+
+
+
+
+
+
+ {submission.course_name || 'Kurs'}
+
+
+ {submission.title}
+
+
+
+
+
+
+
+ {submission.date_text || 'Frist in den Details'}
+
+ {submission.uploaded_count !== null && submission.uploaded_count !== undefined && (
+
+
+ {submission.uploaded_count} {submission.uploaded_count === 1 ? 'Datei' : 'Dateien'} abgegeben
+
+ )}
+
+
+
+
+
+);
+
+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}
+
+
+
void onRefresh()} className="btn btn-secondary inline-flex items-center gap-2">
+ Aktualisieren
+
+
+
+ {(error || refreshError) &&
{error || refreshError}
}
+
+
+
+
+
+ {detail.own_files.length === 0 &&
Du hast noch keine Datei abgegeben.
}
+ {detail.own_files.map(file => (
+
void download(file.download_ref, file.name)} title="Datei herunterladen"> {detail.can_delete && setDeleteTarget(file.index)} title="Datei löschen"> } }
+ />
+ ))}
+
+ {uploadAllowed ? (
+
+
+
inputRef.current?.click()} className="flex w-full flex-col items-center justify-center rounded-xl border border-dashed border-primary-300 px-4 py-7 text-center text-primary-800 transition-colors hover:bg-primary-100/70 dark:border-primary-800 dark:text-primary-200 dark:hover:bg-primary-950/50">
+
+ Dateien auswählen
+ {detail.allowed_file_types.join(', ') || 'Erlaubte Dateitypen ansehen'} · {multipleFilesAllowed ? 'Mehrere Dateien möglich' : 'Maximal eine Datei'}
+
+ {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`}> } />)}
}
+
void upload()} disabled={!selectedFiles.length || busy} className="btn btn-primary mt-3 w-full disabled:cursor-not-allowed disabled:opacity-50">{busy ? 'Wird hochgeladen …' : 'Hochladen'}
+
+ ) : (
+ {attemptClosed ? 'Diese Abgabe erlaubt keine weiteren Versuche.' : detail.status === 'closed' ? 'Die Abgabe ist geschlossen.' : 'Das Hochladen ist derzeit nicht möglich.'}
+ )}
+ {uploadStatuses.length > 0 && }
+
+
+
+
+
+ Zeitfenster
+
Start {detail.start || 'Nicht angegeben'}
Frist {detail.deadline || 'Nicht angegeben'}
Automatische Löschung {detail.automatic_deletion || 'Nicht angegeben'}
+
+ Regeln
Dateitypen {detail.allowed_file_types.join(', ') || 'Portal entscheidet'}
Maximale Größe {detail.max_file_size || 'Portal entscheidet'}
Mehrere Dateien {detail.allows_multiple_files === false ? 'Nicht erlaubt' : 'Erlaubt'}
Weitere Versuche {detail.allows_multiple_attempts === false ? 'Nicht erlaubt' : 'Erlaubt'} {detail.additional_text && {detail.additional_text}
}
+ {detail.public_files.length > 0 && Weitere Dateien
{detail.public_files.map(file =>
void download(file.download_ref, file.name)} className="rounded-lg p-2 text-surface-500 hover:bg-surface-100 hover:text-primary-700 dark:hover:bg-surface-800 dark:hover:text-primary-300" title="Datei herunterladen"> } />)} }
+
+
+
+ {deleteTarget && Datei löschen? Das Schulportal verlangt dein Passwort, um diese Datei endgültig zu löschen.
{deleteError &&
{deleteError}
}
Passwort { setPassword(event.target.value); setDeleteError(''); }} autoFocus className="input mt-1 w-full" autoComplete="current-password" /> { setDeleteTarget(null); setPassword(''); setDeleteError(''); }}>Abbrechen void removeFile()}>Endgültig löschen
}
+
+ );
+};
+
+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 ? (
+
+
+
+
navigate(`${basePath}/courses`)}
+ aria-label="Zurück zu Mein Unterricht"
+ >
+
+
+
+
Abgaben
+
Upload-Aufträge und Fristen
+
+
+ void loadList()}
+ disabled={loading}
+ >
+
+ Aktualisieren
+
+
+ {error &&
{error}
}
+ {!loading && !error &&
Aufträge
{submissions.length}
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.'}
navigate(listPath)}>Zurück zu den Abgaben
+ )}
+
+ );
+};
+
+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