feat(frontend): redesign sessions, courses, live view, and student check-in
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user