feat(frontend): redesign sessions, courses, live view, and student check-in

This commit is contained in:
2026-04-28 19:15:39 +02:00
parent 60c871dec0
commit 0298e03781
4 changed files with 883 additions and 516 deletions

View File

@@ -1,188 +1,97 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Student } from '$lib/types';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
let courses = $state<Course[]>([]);
let selectedCourseId = $state<number | null>(null);
let students = $state<Student[]>([]);
let newCourseName = $state('');
let newCourseSemester = $state('');
let newStudentName = $state('');
let loading = $state(false);
let courses = $state<Course[]>([]);
let newCourseName = $state('');
let newCourseSemester = $state('');
onMount(async () => {
await loadCourses();
});
onMount(async () => {
courses = await api.admin.courses.list();
});
async function loadCourses() {
courses = await api.admin.courses.list();
if (courses.length > 0 && selectedCourseId === null) {
selectedCourseId = courses[0].id;
}
}
$effect(() => {
if (selectedCourseId !== null) {
loadStudents(selectedCourseId);
}
});
async function loadStudents(courseId: number) {
students = await api.admin.courses.listStudents(courseId);
}
async function createCourse() {
try {
const course = await api.admin.courses.create(newCourseName, newCourseSemester);
newCourseName = '';
newCourseSemester = '';
await loadCourses();
selectedCourseId = course.id;
} catch (e) {
alert(e);
}
}
async function addStudent() {
if (!selectedCourseId) return;
try {
await api.admin.courses.addStudent(selectedCourseId, newStudentName);
newStudentName = '';
await loadStudents(selectedCourseId);
} catch (e) {
alert(e);
}
}
async function deleteStudent(id: number) {
if (!confirm('Are you sure?')) return;
try {
await api.admin.students.delete(id);
if (selectedCourseId) await loadStudents(selectedCourseId);
} catch (e) {
alert(e);
}
}
let fileInput: HTMLInputElement;
async function handleImport() {
if (!selectedCourseId || !fileInput.files?.[0]) return;
try {
await api.admin.courses.importStudents(selectedCourseId, fileInput.files[0]);
await loadStudents(selectedCourseId);
fileInput.value = '';
} catch (e) {
alert(e);
}
}
async function createCourse(e: Event) {
e.preventDefault();
if (!newCourseName.trim() || !newCourseSemester.trim()) return;
try {
await api.admin.courses.create(newCourseName.trim(), newCourseSemester.trim());
newCourseName = '';
newCourseSemester = '';
courses = await api.admin.courses.list();
} catch (e) { alert(e); }
}
</script>
<h1>Courses & Students</h1>
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
<div class="management-grid">
<div class="courses-panel">
<h2>Manage Courses</h2>
<form onsubmit={createCourse}>
<input bind:value={newCourseName} placeholder="Course Name (e.g. FP)" required />
<input bind:value={newCourseSemester} placeholder="Semester (e.g. SS2026)" required />
<button type="submit">Create Course</button>
</form>
<div class="course-list">
{#each courses as course}
<div
class="course-item"
class:selected={selectedCourseId === course.id}
onclick={() => selectedCourseId = course.id}
>
<strong>{course.name}</strong> ({course.semester})
</div>
{/each}
</div>
<!-- Header -->
<header>
<div class="eyebrow">Verwaltung</div>
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
Kurse
<span style="color:var(--ink-4);font-size:20px;font-weight:400"> · {courses.length}</span>
</div>
</header>
<div class="students-panel">
{#if selectedCourseId}
{@const selectedCourse = courses.find(c => c.id === selectedCourseId)}
<h2>Students in {selectedCourse?.name}</h2>
<div class="student-actions">
<form onsubmit={addStudent} style="display: inline-block;">
<input bind:value={newStudentName} placeholder="Student Name" required />
<button type="submit">Add Student</button>
</form>
<div class="import-box">
<span>Import CSV (name header):</span>
<input type="file" accept=".csv" bind:this={fileInput} onchange={handleImport} />
</div>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each students as student}
<tr>
<td>{student.id}</td>
<td>{student.name}</td>
<td>
<button onclick={() => deleteStudent(student.id)}>Delete</button>
</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p>Select a course to manage students.</p>
{/if}
<!-- Create course form -->
<section class="card" style="overflow:hidden">
<div style="padding:14px 18px;border-bottom:1px solid var(--rule)">
<div class="serif" style="font-size:18px;font-weight:500">Neuen Kurs anlegen</div>
<UnderlineStroke width={160} />
</div>
<form onsubmit={createCourse} style="padding:16px 18px;display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:4px">
<label class="tiny" style="color:var(--ink-3)">Kursname</label>
<input class="input" bind:value={newCourseName} placeholder="z.B. Funktionale Programmierung" style="width:260px" required />
</div>
<div style="display:flex;flex-direction:column;gap:4px">
<label class="tiny" style="color:var(--ink-3)">Semester</label>
<input class="input" bind:value={newCourseSemester} placeholder="z.B. SS2026" style="width:120px" required />
</div>
<button class="btn" type="submit"><Icon name="plus" size={12} /> Kurs anlegen</button>
</form>
</section>
<!-- Courses table -->
<section class="card" style="overflow:hidden">
{#if courses.length === 0}
<div style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">Noch keine Kurse vorhanden.</span>
</div>
{:else}
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">#</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Name</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Semester</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:right">Aktionen</th>
</tr>
</thead>
<tbody>
{#each courses as course, i}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px;color:var(--ink-4)">{i + 1}</span>
</td>
<td style="padding:12px 14px;font-weight:500">{course.name}</td>
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px">{course.semester}</span>
</td>
<td style="padding:12px 14px;text-align:right">
<div style="display:flex;gap:6px;justify-content:flex-end">
<a href="/admin/students" class="btn ghost sm">Studierende</a>
<a href="/admin/sessions" class="btn ghost sm">Sitzungen</a>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
</div>
<style>
.management-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 30px;
}
.course-item {
padding: 10px;
border: 1px solid #eee;
margin-bottom: 5px;
cursor: pointer;
border-radius: 4px;
}
.course-item.selected {
background: #e7f1ff;
border-color: #007bff;
}
.student-actions {
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.import-box {
margin-top: 10px;
font-size: 0.9em;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 10px;
border-bottom: 1px solid #eee;
}
input {
padding: 6px;
margin-right: 5px;
}
</style>

View File

@@ -1,10 +1,234 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
// Stub — full implementation in Phase 6
import { api } from '$lib/api';
import type { Course, Session, Slot, Student, Attendance, Note } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import StatusPill from '$lib/components/StatusPill.svelte';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import SeatMap from '$lib/components/SeatMap.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
import Tally from '$lib/components/Tally.svelte';
const slotId = $derived(parseInt(($page.params as Record<string, string>).slotId));
let slot = $state<Slot | null>(null);
let session = $state<Session | null>(null);
let students = $state<Student[]>([]);
let attendances = $state<Attendance[]>([]);
let notes = $state<Note[]>([]);
let selectedStudentId = $state<number | null>(null);
let loading = $state(true);
let pollInterval: ReturnType<typeof setInterval> | null = null;
onMount(async () => {
await loadData();
loading = false;
});
onDestroy(() => {
if (pollInterval) clearInterval(pollInterval);
});
$effect(() => {
if (slot?.status === 'open') {
if (!pollInterval) {
pollInterval = setInterval(loadData, 6000);
}
} else {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
});
async function loadData() {
try {
// Find which session/course contains this slot by searching all courses
const courses: Course[] = await api.admin.courses.list();
let found = false;
for (const course of courses) {
const sessions: Session[] = await api.admin.sessions.list(course.id);
for (const sess of sessions) {
const matchingSlot = (sess.slots ?? []).find((sl: Slot) => sl.id === slotId);
if (matchingSlot) {
slot = matchingSlot;
session = sess;
students = await api.admin.courses.listStudents(course.id);
const attendance = await api.admin.sessions.getAttendance(sess.id);
attendances = (attendance.attendances ?? []).filter((a: Attendance) => a.slot_id === slotId);
notes = await api.admin.slots.getNotes(slotId);
found = true;
break;
}
}
if (found) break;
}
} catch (e) {
console.error(e);
}
}
async function updateStatus(status: string) {
if (!slot) return;
try {
await api.admin.slots.updateStatus(slot.id, status);
await loadData();
} catch (e) { alert(e); }
}
function copyLink() {
if (!slot?.code) return;
const url = `${window.location.origin}/s/${slot.code}`;
navigator.clipboard.writeText(url);
}
async function toggleAttendance(studentId: number) {
if (!slot) return;
const existing = attendances.find((a: Attendance) => a.student_id === studentId);
try {
if (existing) {
await api.admin.slots.deleteAttendance(slot.id, studentId);
} else {
await api.admin.slots.addAttendance(slot.id, studentId);
}
await loadData();
} catch (e) { alert(e); }
}
const presentCount = $derived(attendances.length);
const absentCount = $derived(students.length - presentCount);
const bonusCount = $derived(students.filter((s: Student) => {
// Bonus eligibility would require cross-session data; show attendees as placeholder
return attendances.some((a: Attendance) => a.student_id === s.id);
}).length);
function weekLabel(n: number): string {
return `W${String(n).padStart(2, '0')}`;
}
</script>
<div style="padding:28px 36px">
<span class="eyebrow">Tutor:innen-Ansicht · Live</span>
<h1 class="h1" style="font-family:var(--serif)">Slot {slotId}</h1>
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
{#if loading}
<div style="padding:48px;text-align:center">
<span class="small" style="color:var(--ink-4)">Wird geladen…</span>
</div>
{:else if !slot || !session}
<div style="padding:48px;text-align:center">
<span class="small" style="color:var(--ink-4)">Slot nicht gefunden.</span>
</div>
{:else}
<!-- Header -->
<header style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div>
<div class="eyebrow">Tutor:innen-Ansicht · Live</div>
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
{weekLabel(session.week_nr)} · <span class="marker">{session.date}</span>
</div>
<div class="small" style="margin-top:6px;color:var(--ink-3)">
{slot.start_time}{slot.end_time}
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
{#if slot.code}
<span class="mono" style="font-size:22px;letter-spacing:0.12em;font-weight:600">{slot.code}</span>
{/if}
<StatusPill status={slot.status} />
{#if slot.code}
<button class="btn ghost" onclick={copyLink}><Icon name="copy" size={12} /> Kopieren</button>
{/if}
{#if slot.status === 'closed'}
<button class="btn" onclick={() => updateStatus('open')}><Icon name="open" size={12} /> Öffnen</button>
{:else if slot.status === 'open'}
<button class="btn" onclick={() => updateStatus('locked')}><Icon name="lock" size={12} /> Sperren</button>
{:else if slot.status === 'locked'}
<button class="btn ghost" onclick={() => updateStatus('open')}>Öffnen</button>
{/if}
</div>
</header>
<!-- 2-column grid -->
<div style="display:grid;grid-template-columns:1fr 380px;gap:28px;align-items:start">
<!-- Left: SeatMap + Tally -->
<div style="display:flex;flex-direction:column;gap:16px">
<div>
<div class="serif" style="font-size:18px;font-weight:500">Sitzplan</div>
<UnderlineStroke width={70} />
</div>
<div class="card" style="overflow:hidden;padding:16px">
<SeatMap variant="tutor" scale={0.78} />
</div>
<!-- Tally row -->
<div class="card" style="padding:16px 20px;display:flex;gap:24px;align-items:center">
<Tally label="Anwesend" value={presentCount} total={students.length} />
<div style="width:1px;height:32px;background:var(--rule)"></div>
<Tally label="Fehlt" value={absentCount} total={students.length} />
<div style="margin-left:auto">
<button class="btn ghost sm" onclick={() => selectedStudentId = null}>
Manuell eintragen
</button>
</div>
</div>
<!-- Manual attendance toggle -->
{#if students.length > 0}
<section class="card" style="overflow:hidden">
<div style="padding:10px 14px;border-bottom:1px solid var(--rule)">
<span class="tiny" style="color:var(--ink-3);font-family:var(--mono);text-transform:uppercase;letter-spacing:0.08em">Anwesenheit manuell</span>
</div>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tbody>
{#each students as student, i}
{@const present = attendances.some((a: Attendance) => a.student_id === student.id)}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:8px 14px">
<div style="display:flex;align-items:center;gap:8px">
<span style="width:20px;height:20px;border-radius:50%;background:var(--paper-2);color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;flex-shrink:0">
{student.name.split(' ').map((w: string) => w[0]).slice(0, 2).join('').toUpperCase()}
</span>
{student.name}
</div>
</td>
<td style="padding:8px 14px;text-align:right">
<button
class="btn {present ? '' : 'ghost'} sm"
style={present ? 'background:rgba(74,107,58,0.14);color:var(--ink);border-color:rgba(74,107,58,0.3)' : ''}
onclick={() => toggleAttendance(student.id)}
>
{#if present}
<Icon name="check" size={12} /> Anwesend
{:else}
— Abwesend
{/if}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</section>
{/if}
</div>
<!-- Right: NoteEditor -->
<div>
<NoteEditor
{slotId}
{students}
{attendances}
{notes}
{selectedStudentId}
onStudentSelect={(id) => { selectedStudentId = id; }}
weekNr={session.week_nr}
/>
</div>
</div>
{/if}
</div>

View File

@@ -1,241 +1,214 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Room, Tutor, Session } from '$lib/types';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course, Room, Session, Slot } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import StatusPill from '$lib/components/StatusPill.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
let courses = $state<Course[]>([]);
let selectedCourseId = $state<number | null>(null);
let rooms = $state<Room[]>([]);
let sessions = $state<Session[]>([]);
// New Session Form
let weekNr = $state(1);
let date = $state(new Date().toISOString().split('T')[0]);
let courses = $state<Course[]>([]);
let selectedCourseId = $state<number | null>(null);
let rooms = $state<Room[]>([]);
let sessions = $state<Session[]>([]);
// New Slot Form
let selectedSessionId = $state<number | null>(null);
let slotTutorId = $state<number | null>(null);
let slotRoomId = $state<number | null>(null);
let startTime = $state('09:00');
let endTime = $state('11:00');
let weekNr = $state(1);
let date = $state(new Date().toISOString().split('T')[0]);
onMount(async () => {
courses = await api.admin.courses.list();
rooms = await api.admin.rooms.list();
if (courses.length > 0) {
selectedCourseId = courses[0].id;
}
});
let selectedSessionId = $state<number | null>(null);
let slotTutorId = $state<number | null>(null);
let slotRoomId = $state<number | null>(null);
let startTime = $state('09:00');
let endTime = $state('11:00');
$effect(() => {
if (selectedCourseId) {
loadSessions(selectedCourseId);
}
});
onMount(async () => {
courses = await api.admin.courses.list();
rooms = await api.admin.rooms.list();
if (courses.length > 0) selectedCourseId = courses[0].id;
});
async function loadSessions(courseId: number) {
sessions = await api.admin.sessions.list(courseId);
}
$effect(() => {
if (selectedCourseId) loadSessions(selectedCourseId);
});
async function createSession() {
if (!selectedCourseId) return;
try {
await api.admin.sessions.create(selectedCourseId, weekNr, date);
loadSessions(selectedCourseId);
} catch (e) {
alert(e);
}
}
async function loadSessions(courseId: number) {
sessions = await api.admin.sessions.list(courseId);
}
async function createSlot() {
if (!selectedSessionId || !slotTutorId) return;
try {
await api.admin.slots.create(
selectedSessionId,
slotTutorId,
startTime,
endTime,
slotRoomId || undefined
);
if (selectedCourseId) loadSessions(selectedCourseId);
selectedSessionId = null;
} catch (e) {
alert(e);
}
}
async function createSession(e: Event) {
e.preventDefault();
if (!selectedCourseId) return;
try {
await api.admin.sessions.create(selectedCourseId, weekNr, date);
await loadSessions(selectedCourseId);
weekNr++;
} catch (e) { alert(e); }
}
async function deleteSlot(id: number) {
if (!confirm('Are you sure?')) return;
try {
await api.admin.slots.delete(id);
if (selectedCourseId) loadSessions(selectedCourseId);
} catch (e) {
alert(e);
}
}
async function createSlot(e: Event) {
e.preventDefault();
if (!selectedSessionId || !slotTutorId) return;
try {
await api.admin.slots.create(selectedSessionId, slotTutorId, startTime, endTime, slotRoomId || undefined);
if (selectedCourseId) await loadSessions(selectedCourseId);
selectedSessionId = null;
slotTutorId = null;
} catch (e) { alert(e); }
}
async function deleteSlot(id: number) {
if (!confirm('Slot wirklich löschen?')) return;
try {
await api.admin.slots.delete(id);
if (selectedCourseId) await loadSessions(selectedCourseId);
} catch (e) { alert(e); }
}
const selectedCourse = $derived(courses.find((c: Course) => c.id === selectedCourseId) ?? null);
</script>
<h1>Schedule Sessions & Slots</h1>
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
<div class="management-grid">
<div class="sessions-list">
<div class="course-selector">
<label>Course:</label>
<select bind:value={selectedCourseId}>
{#each courses as course}
<option value={course.id}>{course.name}</option>
{/each}
</select>
</div>
<div class="add-session">
<h3>Add Session</h3>
<div class="form-row">
<input type="number" bind:value={weekNr} placeholder="Week" style="width: 60px" />
<input type="date" bind:value={date} />
<button onclick={createSession}>Add</button>
</div>
</div>
<div class="sessions-grid">
{#each sessions as session}
<div class="session-block">
<div class="session-header">
<strong>Week {session.week_nr}</strong> ({session.date})
<button onclick={() => selectedSessionId = session.id}>+ Add Slot</button>
</div>
<div class="slots-list">
{#each session.slots || [] as slot}
<div class="slot-item">
<span>{slot.start_time}-{slot.end_time}</span>
<button class="delete-btn" onclick={() => deleteSlot(slot.id)}>×</button>
</div>
{/each}
</div>
</div>
{/each}
</div>
<!-- Header -->
<header style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div>
<div class="eyebrow">Sitzungen</div>
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
Planung
{#if selectedCourse}
<span style="color:var(--ink-4);font-size:20px;font-weight:400"> · {selectedCourse.name}</span>
{/if}
</div>
</div>
{#if selectedSessionId}
<div class="modal-overlay">
<div class="modal">
<h2>Add Slot to Session</h2>
<div class="field">
<label>Tutor ID (Mock: 1)</label>
<input type="number" bind:value={slotTutorId} placeholder="Tutor ID" />
</div>
<div class="field">
<label>Room (Optional)</label>
<select bind:value={slotRoomId}>
<option value={null}>None</option>
{#each rooms as room}
<option value={room.id}>{room.name}</option>
{/each}
</select>
</div>
<div class="field">
<label>Start Time</label>
<input type="time" bind:value={startTime} />
</div>
<div class="field">
<label>End Time</label>
<input type="time" bind:value={endTime} />
</div>
<div class="modal-actions">
<button onclick={() => selectedSessionId = null}>Cancel</button>
<button class="primary" onclick={createSlot}>Create Slot</button>
</div>
</div>
</div>
{#if courses.length > 1}
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
{#each courses as course}
<option value={course.id}>{course.name} ({course.semester})</option>
{/each}
</select>
{/if}
</header>
<!-- Add session form -->
<section class="card" style="overflow:hidden">
<div style="padding:14px 18px;border-bottom:1px solid var(--rule)">
<div class="serif" style="font-size:18px;font-weight:500">Neue Sitzung anlegen</div>
<UnderlineStroke width={160} />
</div>
<form onsubmit={createSession} style="padding:16px 18px;display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:4px">
<label class="tiny" style="color:var(--ink-3)">Woche #</label>
<input class="input" type="number" bind:value={weekNr} min="1" style="width:80px" />
</div>
<div style="display:flex;flex-direction:column;gap:4px">
<label class="tiny" style="color:var(--ink-3)">Datum</label>
<input class="input" type="date" bind:value={date} />
</div>
<button class="btn" type="submit"><Icon name="plus" size={12} /> Sitzung anlegen</button>
</form>
</section>
<!-- Sessions list -->
{#if sessions.length === 0}
<div class="card" style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">Noch keine Sitzungen angelegt.</span>
</div>
{:else}
<div style="display:flex;flex-direction:column;gap:12px">
{#each sessions as session}
<section class="card" style="overflow:hidden">
<div style="padding:12px 16px;background:rgba(0,0,0,0.02);border-bottom:1px solid var(--rule);display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;align-items:center;gap:10px">
<span class="mono" style="font-size:11px;color:var(--ink-3)">W{String(session.week_nr).padStart(2, '0')}</span>
<span class="body" style="font-weight:500">{session.date}</span>
</div>
<button class="btn ghost sm" onclick={() => selectedSessionId = session.id}>
<Icon name="plus" size={12} /> Slot hinzufügen
</button>
</div>
{#if (session.slots ?? []).length === 0}
<div style="padding:12px 16px">
<span class="tiny" style="color:var(--ink-4)">Noch kein Slot für diese Sitzung.</span>
</div>
{:else}
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tbody>
{#each session.slots ?? [] as slot, i}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:10px 16px">
<span class="mono" style="font-size:12px">{slot.start_time}{slot.end_time}</span>
</td>
<td style="padding:10px 16px"><StatusPill status={slot.status} /></td>
<td style="padding:10px 16px">
{#if slot.code}
<span class="mono" style="font-size:12px;color:var(--ink-3)">{slot.code}</span>
{:else}
<span class="tiny" style="color:var(--ink-4)"></span>
{/if}
</td>
<td style="padding:10px 16px;text-align:right">
<div style="display:flex;gap:6px;justify-content:flex-end">
{#if slot.status === 'open' || slot.status === 'locked'}
<a href="/admin/live/{slot.id}" class="btn ghost sm">Anzeigen</a>
{/if}
<button
class="btn ghost sm"
style="color:var(--accent);border-color:rgba(138,44,31,0.2)"
onclick={() => deleteSlot(slot.id)}
><Icon name="x" size={12} /></button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
{/each}
</div>
{/if}
</div>
<style>
.course-selector {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.add-session {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.form-row {
display: flex;
gap: 10px;
}
.sessions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.session-block {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.session-header {
background: #eee;
padding: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.slots-list {
padding: 10px;
}
.slot-item {
display: flex;
justify-content: space-between;
padding: 5px 0;
border-bottom: 1px solid #f0f0f0;
}
.delete-btn {
background: none;
border: none;
color: #dc3545;
cursor: pointer;
font-weight: bold;
}
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: white;
padding: 30px;
border-radius: 8px;
width: 400px;
}
.field {
margin-bottom: 15px;
}
.field label {
display: block;
margin-bottom: 5px;
}
.field input, .field select {
width: 100%;
padding: 8px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.primary {
background: #007bff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
}
</style>
<!-- Add slot modal -->
{#if selectedSessionId !== null}
{@const sess = sessions.find((s: Session) => s.id === selectedSessionId)}
<div style="position:fixed;inset:0;background:rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;z-index:100">
<div class="card" style="width:100%;max-width:420px;padding:24px">
<div class="eyebrow" style="margin-bottom:4px">Neuer Slot</div>
<div class="serif" style="font-size:22px;font-weight:500;margin-bottom:2px">
Woche {sess?.week_nr} · {sess?.date}
</div>
<UnderlineStroke width={180} />
<form onsubmit={createSlot} style="margin-top:18px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;flex-direction:column;gap:4px">
<label class="tiny" style="color:var(--ink-3)">Tutor-ID</label>
<input class="input" type="number" bind:value={slotTutorId} placeholder="1" required />
</div>
<div style="display:flex;flex-direction:column;gap:4px">
<label class="tiny" style="color:var(--ink-3)">Raum (optional)</label>
<select class="input" bind:value={slotRoomId}>
<option value={null}>Kein Raum</option>
{#each rooms as room}
<option value={room.id}>{room.name}</option>
{/each}
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div style="display:flex;flex-direction:column;gap:4px">
<label class="tiny" style="color:var(--ink-3)">Beginn</label>
<input class="input" type="time" bind:value={startTime} />
</div>
<div style="display:flex;flex-direction:column;gap:4px">
<label class="tiny" style="color:var(--ink-3)">Ende</label>
<input class="input" type="time" bind:value={endTime} />
</div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:4px">
<button type="button" class="btn ghost" onclick={() => selectedSessionId = null}>Abbrechen</button>
<button type="submit" class="btn"><Icon name="plus" size={12} /> Slot anlegen</button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -1,124 +1,385 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api';
import RoomCanvas from '$lib/RoomCanvas.svelte';
import type { Slot, LayoutElement, Student, Attendance } from '$lib/types';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api';
import type { Slot, Student, Attendance } from '$lib/types';
import SeatMap from '$lib/components/SeatMap.svelte';
import StatusPill from '$lib/components/StatusPill.svelte';
const code = $page.params.code;
let slot = $state<Slot | null>(null);
let layout = $state<LayoutElement[]>([]);
let attendances = $state<any[]>([]);
let students = $state<Student[]>([]);
let selectedStudentId = $state<number | null>(null);
let myAttendance = $derived(attendances.find(a => a.is_mine));
let loading = $state(true);
let error = $state('');
const code = $page.params.code as string;
onMount(async () => {
try {
await loadInfo();
if (!myAttendance && slot?.status === 'open') {
students = await api.checkin.getStudents(code);
}
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
});
type Step = 'loading' | 'error' | 'name' | 'seat' | 'confirmed' | 'locked';
let step = $state<Step>('loading');
let errorMsg = $state('');
async function loadInfo() {
const res = await api.checkin.getInfo(code);
slot = res.slot;
layout = res.layout || [];
attendances = res.attendances || [];
let slot = $state<Slot | null>(null);
let students = $state<Student[]>([]);
let attendances = $state<Attendance[]>([]);
let myAttendance = $state<Attendance | null>(null);
let search = $state('');
let selectedStudent = $state<Student | null>(null);
let isDesktop = $state(false);
onMount(async () => {
const mq = window.matchMedia('(min-width: 900px)');
isDesktop = mq.matches;
mq.addEventListener('change', (e) => { isDesktop = e.matches; });
try {
await loadInfo();
} catch (e: any) {
errorMsg = e.message ?? 'Fehler beim Laden.';
step = 'error';
}
});
async function loadInfo() {
const res = await api.checkin.getInfo(code);
slot = res.slot;
attendances = res.attendances ?? [];
const mine = attendances.find((a: Attendance) => (a as any).is_mine);
if (mine) {
myAttendance = mine;
}
async function handleCheckin(el: LayoutElement) {
if (!slot || slot.status !== 'open') return;
if (el.type !== 'seat') return;
// If already checked in and clicked the same seat, do nothing
if (myAttendance?.seat_id === el.id) return;
// If not checked in, student must be selected
const studentId = myAttendance?.student_id || selectedStudentId;
if (!studentId) {
alert('Please select your name first');
return;
}
try {
await api.checkin.post(code, studentId, el.id);
await loadInfo();
} catch (e: any) {
alert(e.message);
}
if (slot?.status === 'locked') {
step = 'locked';
} else if (mine) {
step = 'confirmed';
} else if (slot?.status === 'open') {
students = await api.checkin.getStudents(code);
step = 'name';
} else {
step = 'locked';
}
}
let occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(id => id !== null) as string[]);
async function selectName(student: Student) {
selectedStudent = student;
search = '';
step = 'seat';
}
async function checkin(seatId?: string) {
if (!selectedStudent) return;
try {
const res = await api.checkin.post(code, selectedStudent.id, seatId);
myAttendance = res;
await loadInfo();
} catch (e: any) {
if (e.message?.includes('already checked in') || e.message?.includes('409')) {
errorMsg = 'Dieser Platz ist bereits belegt.';
} else {
errorMsg = e.message ?? 'Einchecken fehlgeschlagen.';
}
}
}
async function changeSeat() {
step = 'seat';
}
const filteredStudents = $derived(
students.filter((s: Student) => s.name.toLowerCase().includes(search.toLowerCase()))
);
function initials(name: string): string {
return name.split(' ').map((w: string) => w[0]).slice(0, 2).join('').toUpperCase();
}
</script>
<div class="checkin-page">
{#if loading}
<p>Loading...</p>
{:else if error}
<p class="error">{error}</p>
{:else if slot}
<h1>Check-in: {slot.start_time} - {slot.end_time}</h1>
{#if slot.status === 'locked'}
<p class="warning">Check-in is currently locked by the tutor.</p>
{/if}
<div class="paper-bg" style="min-height:100vh">
{#if !myAttendance && slot.status === 'open'}
<div class="identity-selector">
<label for="student">I am:</label>
<select id="student" bind:value={selectedStudentId}>
<option value={null}>Select your name...</option>
{#each students as student}
<option value={student.id}>{student.name}</option>
{/each}
</select>
</div>
{:else if myAttendance}
<p class="success">You are checked in as <strong>{students.find(s => s.id === myAttendance.student_id)?.name || 'Student'}</strong></p>
{/if}
{#if step === 'loading'}
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh">
<span class="body" style="color:var(--ink-4)">Wird geladen…</span>
</div>
<div class="map-container">
<p>Select a seat to check in:</p>
<RoomCanvas
elements={layout}
{occupiedSeatIds}
mySeatId={myAttendance?.seat_id}
onElementClick={handleCheckin}
/>
{:else if step === 'error'}
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:40px 20px">
<div class="card" style="max-width:420px;width:100%;padding:28px;text-align:center">
<div class="serif" style="font-size:24px;font-weight:500;margin-bottom:8px">Fehler</div>
<div class="body" style="color:var(--ink-3)">{errorMsg}</div>
</div>
</div>
{:else if step === 'locked'}
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:40px 20px;flex-direction:column;gap:20px">
<div style="text-align:center">
<div class="eyebrow">Anwesenheit</div>
<div class="serif" style="font-size:32px;font-weight:500;margin-top:4px">
Erfassung abgeschlossen
</div>
{/if}
</div>
</div>
<div class="card" style="max-width:420px;width:100%;padding:24px;text-align:center">
{#if myAttendance}
<div class="stamp" style="margin:0 auto 16px">✓ ANWESEND</div>
<div class="body">Du wurdest als anwesend eingetragen.</div>
{:else}
<div style="color:var(--ink-3)" class="body">Der Check-in-Link ist nicht mehr aktiv.</div>
<div class="tiny" style="color:var(--ink-4);margin-top:8px">Wende dich an deine:n Tutor:in.</div>
{/if}
</div>
{#if slot}
<div class="tiny" style="color:var(--ink-4)">{slot.start_time}{slot.end_time}</div>
{/if}
</div>
<style>
.checkin-page {
max-width: 800px;
margin: 40px auto;
text-align: center;
}
.identity-selector {
margin-bottom: 20px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
select {
padding: 8px;
font-size: 1.1em;
}
.error { color: red; }
.success { color: #28a745; font-size: 1.2em; }
.warning { color: #856404; background: #fff3cd; padding: 10px; border-radius: 4px; }
.map-container {
margin-top: 30px;
}
</style>
{:else if !isDesktop}
<!-- PHONE LAYOUT -->
{#if step === 'name'}
<div style="padding:32px 20px;display:flex;flex-direction:column;gap:20px;max-width:480px;margin:0 auto">
<div style="text-align:center">
<div class="eyebrow">Check-in</div>
<div class="serif" style="font-size:30px;font-weight:500;margin-top:4px">Wer bist du?</div>
<div class="small" style="color:var(--ink-4);margin-top:6px">Wähle deinen Namen aus der Liste.</div>
</div>
{#if errorMsg}
<div class="small" style="color:var(--accent);background:rgba(138,44,31,0.06);border-radius:4px;padding:8px 10px;text-align:center">
{errorMsg}
</div>
{/if}
<input
class="input"
bind:value={search}
placeholder="Name suchen…"
style="font-size:16px"
/>
<div style="display:flex;flex-direction:column;gap:4px;max-height:55vh;overflow-y:auto" class="scroll">
{#each filteredStudents as student}
<button
class="card"
style="padding:12px 16px;display:flex;align-items:center;gap:10px;text-align:left;border:none;cursor:pointer;background:var(--paper);width:100%"
onclick={() => selectName(student)}
>
<span style="width:32px;height:32px;border-radius:50%;background:var(--paper-2);color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;flex-shrink:0">
{initials(student.name)}
</span>
<span class="body">{student.name}</span>
</button>
{:else}
<div style="padding:20px;text-align:center">
<span class="small" style="color:var(--ink-4)">Keine Treffer.</span>
</div>
{/each}
</div>
{#if slot}
<div class="tiny" style="color:var(--ink-4);text-align:center">{slot.start_time}{slot.end_time}</div>
{/if}
</div>
{:else if step === 'seat' && selectedStudent}
<div style="padding:24px 20px;display:flex;flex-direction:column;gap:16px;max-width:480px;margin:0 auto">
<div style="text-align:center">
<div class="eyebrow">Hallo, {selectedStudent.name.split(' ')[0]} 👋</div>
<div class="serif" style="font-size:28px;font-weight:500;margin-top:4px">Wähle deinen Sitz</div>
<div class="small" style="color:var(--ink-4);margin-top:4px">Tippe auf einen freien Platz.</div>
</div>
{#if errorMsg}
<div class="small" style="color:var(--accent);background:rgba(138,44,31,0.06);border-radius:4px;padding:8px 10px;text-align:center">
{errorMsg}
</div>
{/if}
<div style="overflow-x:auto">
<SeatMap
variant="student"
scale={0.46}
onSeatClick={(seat) => checkin(seat.id)}
/>
</div>
<div style="display:flex;gap:16px;justify-content:center" class="tiny">
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:12px;border-radius:50%;background:var(--accent);display:inline-block"></span> Dein Platz
</span>
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:12px;border-radius:50%;background:#d6cdb5;display:inline-block"></span> Belegt
</span>
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:12px;border-radius:50%;background:#fbf7ee;border:1.5px solid var(--ink-2);display:inline-block"></span> Frei
</span>
</div>
<button class="btn ghost" style="font-size:12px" onclick={() => { step = 'name'; selectedStudent = null; }}>
← Zurück
</button>
</div>
{:else if step === 'confirmed' && (myAttendance || selectedStudent)}
<div style="padding:24px 20px;display:flex;flex-direction:column;gap:16px;max-width:480px;margin:0 auto">
<div style="text-align:center">
<div class="serif" style="font-size:28px;font-weight:500">
Du sitzt auf <span class="marker">Platz {myAttendance?.seat_id ?? '—'}</span>
</div>
</div>
<div class="card" style="padding:16px;text-align:center">
<div class="stamp" style="margin:0 auto 12px">✓ ANWESEND</div>
<div class="body">Eingecheckt um {myAttendance?.checked_in_at?.slice(11, 16) ?? '—'} Uhr</div>
</div>
<div style="overflow-x:auto">
<SeatMap variant="student-self" scale={0.46} ownSeat={myAttendance?.seat_id ?? null} />
</div>
<button class="btn ghost sm" style="align-self:center" onclick={changeSeat}>
Sitz wechseln
</button>
</div>
{/if}
{:else}
<!-- DESKTOP LAYOUT -->
{#if step === 'name'}
<div style="max-width:560px;margin:0 auto;padding:60px 20px">
<div style="text-align:center;margin-bottom:32px">
<div class="eyebrow">Check-in</div>
<div class="serif" style="font-size:40px;font-weight:500;margin-top:6px">Wer bist du?</div>
<div class="body" style="color:var(--ink-4);margin-top:8px">Wähle deinen Namen aus der Liste.</div>
</div>
{#if errorMsg}
<div class="small" style="color:var(--accent);background:rgba(138,44,31,0.06);border-radius:4px;padding:8px 10px;margin-bottom:16px;text-align:center">
{errorMsg}
</div>
{/if}
<input
class="input"
bind:value={search}
placeholder="Name suchen…"
style="width:100%;font-size:16px;margin-bottom:16px"
/>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-height:55vh;overflow-y:auto" class="scroll">
{#each filteredStudents as student}
<button
class="card"
style="padding:12px 16px;display:flex;align-items:center;gap:10px;text-align:left;border:none;cursor:pointer;background:var(--paper);width:100%"
onclick={() => selectName(student)}
>
<span style="width:28px;height:28px;border-radius:50%;background:var(--paper-2);color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;flex-shrink:0">
{initials(student.name)}
</span>
<span class="body">{student.name}</span>
</button>
{:else}
<div style="grid-column:1/-1;padding:20px;text-align:center">
<span class="small" style="color:var(--ink-4)">Keine Treffer.</span>
</div>
{/each}
</div>
</div>
{:else if step === 'seat' && selectedStudent}
<div style="display:grid;grid-template-columns:1fr 360px;gap:0;min-height:100vh">
<!-- Left: SeatMap -->
<div style="padding:40px;display:flex;flex-direction:column;gap:20px">
<div>
<div class="eyebrow">Hallo, {selectedStudent.name.split(' ')[0]} 👋</div>
<div class="serif" style="font-size:32px;font-weight:500;margin-top:4px">Wähle deinen Sitz</div>
</div>
{#if errorMsg}
<div class="small" style="color:var(--accent);background:rgba(138,44,31,0.06);border-radius:4px;padding:8px 10px">
{errorMsg}
</div>
{/if}
<SeatMap
variant="student"
scale={0.78}
onSeatClick={(seat) => checkin(seat.id)}
/>
<div style="display:flex;gap:20px" class="tiny">
<span style="display:flex;align-items:center;gap:6px">
<span style="width:14px;height:14px;border-radius:50%;background:var(--accent);display:inline-block"></span> Dein Platz
</span>
<span style="display:flex;align-items:center;gap:6px">
<span style="width:14px;height:14px;border-radius:50%;background:#d6cdb5;display:inline-block"></span> Belegt
</span>
<span style="display:flex;align-items:center;gap:6px">
<span style="width:14px;height:14px;border-radius:50%;background:#fbf7ee;border:1.5px solid var(--ink-2);display:inline-block"></span> Frei
</span>
</div>
</div>
<!-- Right: session info panel -->
<div style="border-left:1px solid var(--rule);padding:40px;display:flex;flex-direction:column;gap:20px">
<div class="card" style="padding:20px">
<div class="eyebrow" style="margin-bottom:6px">Sitzung</div>
{#if slot}
<div class="serif" style="font-size:20px;font-weight:500">{slot.start_time}{slot.end_time}</div>
{/if}
<div style="margin-top:12px"><StatusPill status={slot?.status ?? 'open'} /></div>
</div>
<div class="card" style="padding:16px">
<div class="tiny" style="color:var(--ink-3);margin-bottom:8px">Anwesend</div>
<div class="serif" style="font-size:28px;font-weight:500">{attendances.length}</div>
<div class="tiny" style="color:var(--ink-4);margin-top:4px">von {students.length} Studierenden</div>
</div>
<button class="btn ghost" onclick={() => { step = 'name'; selectedStudent = null; }}>
← Zurück
</button>
</div>
</div>
{:else if step === 'confirmed'}
<div style="display:grid;grid-template-columns:1fr 360px;gap:0;min-height:100vh">
<!-- Left -->
<div style="padding:40px;display:flex;flex-direction:column;gap:20px">
<div>
<div class="eyebrow">Eingecheckt</div>
<div class="serif" style="font-size:32px;font-weight:500;margin-top:4px">
Du sitzt auf <span class="marker">Platz {myAttendance?.seat_id ?? '—'}</span>
</div>
</div>
<SeatMap variant="student-self" scale={0.78} ownSeat={myAttendance?.seat_id ?? null} />
<div style="display:flex;gap:10px">
<button class="btn ghost" onclick={changeSeat}>Sitz wechseln</button>
<button class="btn ghost" onclick={() => window.print()}>Drucken</button>
</div>
</div>
<!-- Right -->
<div style="border-left:1px solid var(--rule);padding:40px;display:flex;flex-direction:column;gap:16px">
<div class="card" style="padding:20px;text-align:center">
<div class="stamp" style="margin:0 auto 12px">✓ ANWESEND</div>
<div class="body" style="font-weight:500">
{myAttendance?.checked_in_at?.slice(11, 16) ?? '—'} Uhr
</div>
</div>
{#if slot}
<div class="card" style="padding:16px">
<div class="eyebrow" style="margin-bottom:6px">Sitzung</div>
<div class="serif" style="font-size:18px;font-weight:500">{slot.start_time}{slot.end_time}</div>
</div>
{/if}
<div class="card" style="padding:16px">
<div class="tiny" style="color:var(--ink-3);margin-bottom:6px">Anwesende</div>
<div class="serif" style="font-size:24px;font-weight:500">{attendances.length} / {students.length}</div>
</div>
</div>
</div>
{/if}
{/if}
</div>