feat(frontend): redesign dashboard, attendance, students, login with paper aesthetic
This commit is contained in:
@@ -1,65 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { token, logout } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { token, logout } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import TutorShell from '$lib/components/TutorShell.svelte';
|
||||
import type { Course } from '$lib/types';
|
||||
|
||||
onMount(() => {
|
||||
if (!$token) {
|
||||
goto('/admin/login');
|
||||
}
|
||||
});
|
||||
let course = $state<Course | null>(null);
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
goto('/admin/login');
|
||||
onMount(async () => {
|
||||
if (!$token) {
|
||||
goto('/admin/login');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const courses = await api.admin.courses.list();
|
||||
if (courses.length > 0) course = courses[0];
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!$token) goto('/admin/login');
|
||||
});
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
goto('/admin/login');
|
||||
}
|
||||
|
||||
const activePath = $derived($page.url.pathname);
|
||||
</script>
|
||||
|
||||
{#if $token}
|
||||
<nav>
|
||||
<div class="nav-content">
|
||||
<div class="links">
|
||||
<a href="/admin">Dashboard</a>
|
||||
<a href="/admin/courses">Courses</a>
|
||||
<a href="/admin/rooms">Rooms</a>
|
||||
<a href="/admin/sessions">Sessions</a>
|
||||
<a href="/admin/attendance">Attendance</a>
|
||||
<a href="/admin/notes">Notes</a>
|
||||
<a href="/admin/export">Export</a>
|
||||
</div>
|
||||
<button on:click={handleLogout}>Logout</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<TutorShell
|
||||
{activePath}
|
||||
courseName={course?.name ?? ''}
|
||||
semester={course?.semester ?? ''}
|
||||
>
|
||||
{#snippet children()}
|
||||
<slot />
|
||||
{/snippet}
|
||||
</TutorShell>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
nav {
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
.nav-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.links a {
|
||||
margin-right: 20px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
}
|
||||
.links a:hover {
|
||||
color: #007bff;
|
||||
}
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,147 +1,214 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { Course, Session, Slot } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { Course, Session, Slot } from '$lib/types';
|
||||
import StatusPill from '$lib/components/StatusPill.svelte';
|
||||
import StatCard from '$lib/components/StatCard.svelte';
|
||||
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
let courses = $state<Course[]>([]);
|
||||
let selectedCourseId = $state<number | null>(null);
|
||||
let sessions = $state<Session[]>([]);
|
||||
let loading = $state(false);
|
||||
let courses = $state<Course[]>([]);
|
||||
let selectedCourseId = $state<number | null>(null);
|
||||
let sessions = $state<Session[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
loading = true;
|
||||
try {
|
||||
courses = await api.admin.courses.list();
|
||||
if (courses.length > 0) {
|
||||
selectedCourseId = courses[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedCourseId !== null) {
|
||||
loadSessions(selectedCourseId);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadSessions(courseId: number) {
|
||||
loading = true;
|
||||
try {
|
||||
sessions = await api.admin.sessions.list(courseId);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
onMount(async () => {
|
||||
loading = true;
|
||||
try {
|
||||
courses = await api.admin.courses.list();
|
||||
if (courses.length > 0) selectedCourseId = courses[0].id;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function updateStatus(slotId: number, status: string) {
|
||||
try {
|
||||
await api.admin.slots.updateStatus(slotId, status);
|
||||
if (selectedCourseId) loadSessions(selectedCourseId);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
if (selectedCourseId !== null) loadSessions(selectedCourseId);
|
||||
});
|
||||
|
||||
function copyLink(code: string) {
|
||||
const url = `${window.location.origin}/s/${code}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
alert('Link copied to clipboard');
|
||||
async function loadSessions(courseId: number) {
|
||||
loading = true;
|
||||
try {
|
||||
sessions = await api.admin.sessions.list(courseId);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus(slotId: number, status: string) {
|
||||
try {
|
||||
await api.admin.slots.updateStatus(slotId, status);
|
||||
if (selectedCourseId) loadSessions(selectedCourseId);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
}
|
||||
|
||||
function copyLink(code: string) {
|
||||
const url = `${window.location.origin}/s/${code}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
}
|
||||
|
||||
// Derived stats
|
||||
const allSlots = $derived(sessions.flatMap((s: Session) => s.slots ?? []));
|
||||
const openSlots = $derived(allSlots.filter((s: Slot) => s.status === 'open'));
|
||||
const lockedSlots = $derived(allSlots.filter((s: Slot) => s.status === 'locked'));
|
||||
const currentWeekNr = $derived(sessions[0]?.week_nr ?? 1);
|
||||
const selectedCourse = $derived(courses.find((c: Course) => c.id === selectedCourseId) ?? null);
|
||||
|
||||
// Flat list for the table: sessions × slots, sorted newest first
|
||||
interface SlotRow {
|
||||
slot: Slot;
|
||||
session: Session;
|
||||
}
|
||||
const slotRows = $derived<SlotRow[]>(
|
||||
sessions.flatMap((session: Session) =>
|
||||
(session.slots ?? []).map((slot: Slot) => ({ slot, session }))
|
||||
)
|
||||
);
|
||||
|
||||
function weekLabel(n: number): string {
|
||||
return `W${String(n).padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Admin Dashboard</h1>
|
||||
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
|
||||
|
||||
<div class="course-selector">
|
||||
<label for="course">Select Course:</label>
|
||||
<select id="course" bind:value={selectedCourseId}>
|
||||
{#each courses as course}
|
||||
<option value={course.id}>{course.name} ({course.semester})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if loading && sessions.length === 0}
|
||||
<p>Loading sessions...</p>
|
||||
{:else}
|
||||
<div class="sessions">
|
||||
{#each sessions as session}
|
||||
<div class="session-card">
|
||||
<h2>Week {session.week_nr} - {session.date}</h2>
|
||||
<div class="slots">
|
||||
{#if session.slots && session.slots.length > 0}
|
||||
{#each session.slots as slot}
|
||||
<div class="slot-row">
|
||||
<div class="slot-info">
|
||||
<span class="status-badge {slot.status}">{slot.status}</span>
|
||||
<strong>{slot.start_time} - {slot.end_time}</strong>
|
||||
{#if slot.code}
|
||||
<code class="checkin-code">{slot.code}</code>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if slot.status === 'closed'}
|
||||
<button onclick={() => updateStatus(slot.id, 'open')}>Open</button>
|
||||
{:else if slot.status === 'open'}
|
||||
<button onclick={() => copyLink(slot.code!)}>Copy Link</button>
|
||||
<button onclick={() => updateStatus(slot.id, 'locked')}>Lock</button>
|
||||
<button onclick={() => updateStatus(slot.id, 'closed')}>Close</button>
|
||||
{:else if slot.status === 'locked'}
|
||||
<button onclick={() => updateStatus(slot.id, 'open')}>Unlock</button>
|
||||
<button onclick={() => updateStatus(slot.id, 'closed')}>Close</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p>No slots scheduled.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Header -->
|
||||
<header style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px">
|
||||
<div>
|
||||
<div class="eyebrow">Dashboard</div>
|
||||
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
|
||||
Diese Woche, <span class="marker">Woche {String(currentWeekNr).padStart(2, '0')}</span>
|
||||
</div>
|
||||
{#if selectedCourse}
|
||||
<div class="small" style="margin-top:6px;color:var(--ink-3)">
|
||||
{selectedCourse.name} · {selectedCourse.semester}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
{#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}
|
||||
<a href={api.admin.export.courseCsv(selectedCourseId ?? 0)} class="btn ghost">
|
||||
<Icon name="download" /> Export
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.course-selector {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.session-card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.slot-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
.status-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.status-badge.closed { background: #6c757d; color: white; }
|
||||
.status-badge.open { background: #28a745; color: white; }
|
||||
.status-badge.locked { background: #ffc107; color: black; }
|
||||
|
||||
.checkin-code {
|
||||
background: #f0f0f0;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.actions button {
|
||||
margin-left: 5px;
|
||||
}
|
||||
</style>
|
||||
<!-- Stat row -->
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px">
|
||||
<StatCard
|
||||
label="Offene Slots"
|
||||
value={openSlots.length}
|
||||
hint={openSlots.length > 0 ? `Code: ${openSlots[0].code ?? '—'}` : 'Kein Slot offen'}
|
||||
accent={openSlots.length > 0 ? 'var(--green)' : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Abgeschlossene Slots"
|
||||
value={lockedSlots.length}
|
||||
hint="Gesperrt"
|
||||
/>
|
||||
<StatCard
|
||||
label="Tutorien bisher"
|
||||
value={sessions.length}
|
||||
hint="Wochen mit Slot"
|
||||
/>
|
||||
<StatCard
|
||||
label="Alle Slots"
|
||||
value={allSlots.length}
|
||||
hint={`${sessions.length} Wochen`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Slots table -->
|
||||
<section class="card" style="overflow:hidden">
|
||||
<div style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule)">
|
||||
<div>
|
||||
<div class="serif" style="font-size:18px;font-weight:500">Slots</div>
|
||||
<UnderlineStroke width={50} />
|
||||
</div>
|
||||
<div class="small" style="color:var(--ink-3)">Neueste zuerst</div>
|
||||
</div>
|
||||
|
||||
{#if loading && slotRows.length === 0}
|
||||
<div style="padding:32px;text-align:center">
|
||||
<span class="small" style="color:var(--ink-4)">Wird geladen…</span>
|
||||
</div>
|
||||
{:else if slotRows.length === 0}
|
||||
<div style="padding:32px;text-align:center">
|
||||
<span class="small" style="color:var(--ink-4)">Noch keine Slots geplant.</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">Woche</th>
|
||||
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Datum</th>
|
||||
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Zeit</th>
|
||||
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Status</th>
|
||||
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Code</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 slotRows as { slot, session }, 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">{weekLabel(session.week_nr)}</span>
|
||||
</td>
|
||||
<td style="padding:12px 14px">{session.date}</td>
|
||||
<td style="padding:12px 14px">
|
||||
<span class="mono" style="font-size:12px">{slot.start_time}–{slot.end_time}</span>
|
||||
</td>
|
||||
<td style="padding:12px 14px"><StatusPill status={slot.status} /></td>
|
||||
<td style="padding:12px 14px">
|
||||
{#if slot.code}
|
||||
<span class="mono" style="font-size:12px;letter-spacing:0.08em">{slot.code}</span>
|
||||
{:else}
|
||||
<span class="tiny" style="color:var(--ink-4)">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td style="padding:12px 14px;text-align:right">
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
{#if slot.status === 'closed'}
|
||||
<button class="btn sm" onclick={() => updateStatus(slot.id, 'open')}>
|
||||
<Icon name="open" size={12} /> Öffnen
|
||||
</button>
|
||||
{:else if slot.status === 'open'}
|
||||
{#if slot.code}
|
||||
<button class="btn ghost sm" onclick={() => copyLink(slot.code!)}>
|
||||
<Icon name="copy" size={12} /> Kopieren
|
||||
</button>
|
||||
{/if}
|
||||
<a href="/admin/live/{slot.id}" class="btn ghost sm">Anzeigen</a>
|
||||
<button class="btn sm" onclick={() => updateStatus(slot.id, 'locked')}>
|
||||
<Icon name="lock" size={12} /> Sperren
|
||||
</button>
|
||||
{:else if slot.status === 'locked'}
|
||||
<a href="/admin/live/{slot.id}" class="btn ghost sm">Anzeigen</a>
|
||||
<button class="btn ghost sm" onclick={() => updateStatus(slot.id, 'open')}>Öffnen</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,111 +1,220 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { Course, Session, Student, Attendance } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { Course, Session, Student, Attendance } 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 sessions = $state<Session[]>([]);
|
||||
let selectedSessionId = $state<number | null>(null);
|
||||
let courses = $state<Course[]>([]);
|
||||
let selectedCourseId = $state<number | null>(null);
|
||||
let sessions = $state<Session[]>([]);
|
||||
let students = $state<Student[]>([]);
|
||||
// Map: slotId → Attendance[]
|
||||
let attendanceBySlot = $state<Record<number, Attendance[]>>({});
|
||||
let allSlotIds = $state<number[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
let data = $state<{
|
||||
students: Student[],
|
||||
slots: any[],
|
||||
attendances: Attendance[]
|
||||
} | null>(null);
|
||||
onMount(async () => {
|
||||
courses = await api.admin.courses.list();
|
||||
if (courses.length > 0) selectedCourseId = courses[0].id;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
courses = await api.admin.courses.list();
|
||||
if (courses.length > 0) selectedCourseId = courses[0].id;
|
||||
});
|
||||
$effect(() => {
|
||||
if (selectedCourseId) loadMatrix(selectedCourseId);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedCourseId) {
|
||||
api.admin.sessions.list(selectedCourseId).then(res => {
|
||||
sessions = res;
|
||||
if (sessions.length > 0) selectedSessionId = sessions[0].id;
|
||||
});
|
||||
}
|
||||
});
|
||||
async function loadMatrix(courseId: number) {
|
||||
loading = true;
|
||||
try {
|
||||
sessions = await api.admin.sessions.list(courseId);
|
||||
students = await api.admin.courses.listStudents(courseId);
|
||||
|
||||
$effect(() => {
|
||||
if (selectedSessionId) {
|
||||
api.admin.sessions.getAttendance(selectedSessionId).then(res => data = res);
|
||||
}
|
||||
});
|
||||
// Load attendance for each session in parallel
|
||||
const perSession = await Promise.all(
|
||||
sessions.map((s: Session) => api.admin.sessions.getAttendance(s.id))
|
||||
);
|
||||
|
||||
async function toggleAttendance(slotId: number, studentId: number) {
|
||||
if (!data) return;
|
||||
const existing = data.attendances.find(a => a.slot_id === slotId && a.student_id === studentId);
|
||||
try {
|
||||
if (existing) {
|
||||
await api.admin.slots.deleteAttendance(slotId, studentId);
|
||||
} else {
|
||||
await api.admin.slots.addAttendance(slotId, studentId);
|
||||
}
|
||||
data = await api.admin.sessions.getAttendance(selectedSessionId!);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
const slotIds: number[] = [];
|
||||
const newMap: Record<number, Attendance[]> = {};
|
||||
|
||||
perSession.forEach((d: { students: Student[]; slots: any[]; attendances: Attendance[] }) => {
|
||||
(d.slots ?? []).forEach((slot: any) => {
|
||||
slotIds.push(slot.id);
|
||||
newMap[slot.id] = (d.attendances ?? []).filter((a: Attendance) => a.slot_id === slot.id);
|
||||
});
|
||||
});
|
||||
|
||||
allSlotIds = slotIds;
|
||||
attendanceBySlot = newMap;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAttendance(slotId: number, studentId: number) {
|
||||
const existing = (attendanceBySlot[slotId] ?? []).find((a: Attendance) => a.student_id === studentId);
|
||||
try {
|
||||
if (existing) {
|
||||
await api.admin.slots.deleteAttendance(slotId, studentId);
|
||||
} else {
|
||||
await api.admin.slots.addAttendance(slotId, studentId);
|
||||
}
|
||||
if (selectedCourseId) await loadMatrix(selectedCourseId);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
}
|
||||
|
||||
function isPresent(slotId: number, studentId: number): boolean {
|
||||
return (attendanceBySlot[slotId] ?? []).some((a: Attendance) => a.student_id === studentId);
|
||||
}
|
||||
|
||||
function presentCount(studentId: number): number {
|
||||
return allSlotIds.filter((sid) => isPresent(sid, studentId)).length;
|
||||
}
|
||||
|
||||
function absentCount(studentId: number): number {
|
||||
return allSlotIds.length - presentCount(studentId);
|
||||
}
|
||||
|
||||
// Bonus: student qualifies if they missed at most 1 session
|
||||
function bonusEligible(studentId: number): boolean {
|
||||
return allSlotIds.length > 0 && absentCount(studentId) <= 1;
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
return name.split(' ').map((w: string) => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
}
|
||||
|
||||
const selectedCourse = $derived(courses.find((c: Course) => c.id === selectedCourseId) ?? null);
|
||||
</script>
|
||||
|
||||
<h1>Attendance Matrix</h1>
|
||||
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
|
||||
|
||||
<div class="selectors">
|
||||
<select bind:value={selectedCourseId}>
|
||||
{#each courses as course}
|
||||
<option value={course.id}>{course.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if sessions.length > 0}
|
||||
<select bind:value={selectedSessionId}>
|
||||
{#each sessions as session}
|
||||
<option value={session.id}>Week {session.week_nr} ({session.date})</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data}
|
||||
<div class="matrix-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
{#each data.slots as slot}
|
||||
<th>{slot.start_time}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.students as student}
|
||||
<tr>
|
||||
<td>{student.name}</td>
|
||||
{#each data.slots as slot}
|
||||
{@const present = data.attendances.some(a => a.slot_id === slot.id && a.student_id === student.id)}
|
||||
<td class="cell" class:present onclick={() => toggleAttendance(slot.id, student.id)}>
|
||||
{present ? '✓' : ''}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Header -->
|
||||
<header style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px">
|
||||
<div>
|
||||
<div class="eyebrow">Anwesenheit</div>
|
||||
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
|
||||
Kursmatrix · <span class="marker">{selectedCourse?.semester ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Loading matrix...</p>
|
||||
{/if}
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
{#if courses.length > 1}
|
||||
<select class="input" style="font-size:12px" bind:value={selectedCourseId}>
|
||||
{#each courses as course}
|
||||
<option value={course.id}>{course.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
{#if selectedCourseId}
|
||||
<a href={api.admin.export.courseCsv(selectedCourseId)} class="btn ghost sm">
|
||||
<Icon name="download" size={12} /> CSV
|
||||
</a>
|
||||
<a href={api.admin.export.courseMd(selectedCourseId)} class="btn ghost sm">
|
||||
<Icon name="download" size={12} /> Markdown
|
||||
</a>
|
||||
<a href={api.admin.export.backup()} class="btn ghost sm">
|
||||
<Icon name="download" size={12} /> SQLite Backup
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.selectors { margin-bottom: 20px; }
|
||||
select { margin-right: 10px; padding: 5px; }
|
||||
.matrix-container { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; background: white; }
|
||||
th, td { border: 1px solid #ddd; padding: 10px; text-align: center; }
|
||||
th { background: #f8f9fa; }
|
||||
.cell { cursor: pointer; width: 60px; height: 40px; }
|
||||
.cell:hover { background: #f0f0f0; }
|
||||
.cell.present { background: #d4edda; color: #155724; font-weight: bold; }
|
||||
</style>
|
||||
<!-- Matrix table -->
|
||||
<section class="card" style="overflow:hidden">
|
||||
<div style="padding:14px 18px;border-bottom:1px solid var(--rule);display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<div class="serif" style="font-size:18px;font-weight:500">Pro Studierende:r</div>
|
||||
<UnderlineStroke width={140} />
|
||||
</div>
|
||||
{#if loading}
|
||||
<span class="tiny" style="color:var(--ink-4)">Wird geladen…</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if students.length === 0}
|
||||
<div style="padding:32px;text-align:center">
|
||||
<span class="small" style="color:var(--ink-4)">Keine Studierenden gefunden.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div style="overflow-x:auto">
|
||||
<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">Studierende:r</th>
|
||||
{#each sessions as session, i}
|
||||
<th style="padding:10px 10px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:center">
|
||||
W{String(session.week_nr).padStart(2, '0')}
|
||||
</th>
|
||||
{/each}
|
||||
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:center">Anwesend</th>
|
||||
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:center">Bonus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each students as student, 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">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="width:22px;height:22px;border-radius:50%;background:var(--paper-2);color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:9px;font-weight:600;flex-shrink:0">
|
||||
{initials(student.name)}
|
||||
</span>
|
||||
<span>{student.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
{#each sessions as session}
|
||||
{@const slotIds = (session.slots ?? []).map((sl: any) => sl.id)}
|
||||
{@const sessionPresent = slotIds.some((sid: number) => isPresent(sid, student.id))}
|
||||
<td style="padding:10px;text-align:center">
|
||||
{#if slotIds.length > 0}
|
||||
<button
|
||||
style="width:24px;height:24px;border-radius:3px;border:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;background:{sessionPresent ? 'rgba(74,107,58,0.14)' : 'transparent'}"
|
||||
onclick={() => toggleAttendance(slotIds[0], student.id)}
|
||||
title={sessionPresent ? 'Anwesend – klicken zum Entfernen' : 'Abwesend – klicken zum Eintragen'}
|
||||
>
|
||||
{#if sessionPresent}
|
||||
<Icon name="check" size={13} />
|
||||
{:else}
|
||||
<span style="color:var(--ink-4)">—</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<span style="color:var(--ink-4)">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
<td style="padding:12px 14px;text-align:center;font-variant-numeric:tabular-nums">
|
||||
{presentCount(student.id)} / {allSlotIds.length}
|
||||
</td>
|
||||
<td style="padding:12px 14px;text-align:center">
|
||||
{#if allSlotIds.length > 0}
|
||||
<span
|
||||
style="width:24px;height:24px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:{bonusEligible(student.id) ? 'rgba(138,44,31,0.08)' : 'transparent'}"
|
||||
title={bonusEligible(student.id) ? 'Bonus-Anspruch: max. 1 Fehlen' : `${absentCount(student.id)} Mal gefehlt — kein Bonus`}
|
||||
>
|
||||
{#if bonusEligible(student.id)}
|
||||
<Icon name="check" size={13} />
|
||||
{:else}
|
||||
<span style="color:var(--ink-4)">—</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span style="color:var(--ink-4)">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,82 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { token } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { token } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let error = '';
|
||||
let loading = false;
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function login() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await api.auth.login(email, password);
|
||||
token.set(res.token);
|
||||
goto('/admin');
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Invalid credentials';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
async function login() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await api.auth.login(email, password);
|
||||
token.set(res.token);
|
||||
goto('/admin');
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Ungültige Anmeldedaten';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-container">
|
||||
<h1>Tutor Login</h1>
|
||||
<form on:submit|preventDefault={login}>
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" bind:value={email} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" bind:value={password} required />
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="paper-bg" style="min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 20px">
|
||||
|
||||
<style>
|
||||
.login-container {
|
||||
max-width: 400px;
|
||||
margin: 100px auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.error {
|
||||
color: red;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
}
|
||||
</style>
|
||||
<!-- Brand -->
|
||||
<div style="margin-bottom:32px;text-align:center">
|
||||
<div class="serif" style="font-size:38px;font-weight:500;letter-spacing:-0.02em">
|
||||
Tutor<span style="color:var(--accent)">·</span>manager
|
||||
</div>
|
||||
<div class="body" style="color:var(--ink-3);margin-top:6px">Anwesenheit & Notizen für Tutorien.</div>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="card" style="width:100%;max-width:420px;padding:26px">
|
||||
<div class="eyebrow" style="margin-bottom:6px">Anmeldung</div>
|
||||
<div class="serif" style="font-size:22px;font-weight:500;margin-bottom:2px">Willkommen zurück</div>
|
||||
<UnderlineStroke width={140} />
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); login(); }} style="margin-top:20px;display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
<label for="email" class="tiny" style="color:var(--ink-3)">E-Mail</label>
|
||||
<input id="email" type="email" class="input" bind:value={email} placeholder="tutor@uni.de" required />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
<label for="password" class="tiny" style="color:var(--ink-3)">Passwort</label>
|
||||
<input id="password" type="password" class="input" bind:value={password} required />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="small" style="color:var(--accent);background:rgba(138,44,31,0.06);border-radius:4px;padding:8px 10px">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn" style="width:100%;padding:11px 14px;margin-top:4px" disabled={loading}>
|
||||
{loading ? 'Anmelden…' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="tiny" style="color:var(--ink-3);margin-top:16px;text-align:center">
|
||||
Nur für Tutor:innen. Studierende nutzen den Link der Tutor:in.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="handwritten" style="margin-top:28px;font-size:18px">~ Donnerstags ab 14 Uhr ~</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,158 @@
|
||||
<script lang="ts">
|
||||
// Stub — full implementation in Phase 6
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { Course, Student } from '$lib/types';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
let courses = $state<Course[]>([]);
|
||||
let selectedCourseId = $state<number | null>(null);
|
||||
let students = $state<Student[]>([]);
|
||||
let newStudentName = $state('');
|
||||
let search = $state('');
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
courses = await api.admin.courses.list();
|
||||
if (courses.length > 0) selectedCourseId = courses[0].id;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedCourseId) loadStudents(selectedCourseId);
|
||||
});
|
||||
|
||||
async function loadStudents(courseId: number) {
|
||||
students = await api.admin.courses.listStudents(courseId);
|
||||
}
|
||||
|
||||
async function addStudent(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!selectedCourseId || !newStudentName.trim()) return;
|
||||
try {
|
||||
await api.admin.courses.addStudent(selectedCourseId, newStudentName);
|
||||
newStudentName = '';
|
||||
await loadStudents(selectedCourseId);
|
||||
} catch (err) { alert(err); }
|
||||
}
|
||||
|
||||
async function deleteStudent(id: number) {
|
||||
if (!confirm('Studierende:n wirklich entfernen?')) return;
|
||||
try {
|
||||
await api.admin.students.delete(id);
|
||||
if (selectedCourseId) await loadStudents(selectedCourseId);
|
||||
} catch (err) { alert(err); }
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!selectedCourseId || !fileInput?.files?.[0]) return;
|
||||
try {
|
||||
await api.admin.courses.importStudents(selectedCourseId, fileInput.files[0]);
|
||||
await loadStudents(selectedCourseId);
|
||||
if (fileInput) fileInput.value = '';
|
||||
} catch (err) { alert(err); }
|
||||
}
|
||||
|
||||
const filtered = $derived(
|
||||
students.filter((s: Student) => s.name.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
|
||||
const selectedCourse = $derived(courses.find((c: Course) => c.id === selectedCourseId) ?? null);
|
||||
|
||||
function initials(name: string): string {
|
||||
return name.split(' ').map((w: string) => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="padding:28px 36px">
|
||||
<span class="eyebrow">Studierende</span>
|
||||
<h1 class="h1" style="font-family:var(--serif)">Studierende</h1>
|
||||
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
|
||||
|
||||
<!-- Header -->
|
||||
<header style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
|
||||
<div>
|
||||
<div class="eyebrow">Studierende</div>
|
||||
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
|
||||
{selectedCourse?.name ?? 'Studierende'}
|
||||
<span style="color:var(--ink-4);font-size:20px;font-weight:400"> · {filtered.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
{#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}
|
||||
|
||||
<!-- Search -->
|
||||
<div style="position:relative">
|
||||
<span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--ink-4);pointer-events:none">
|
||||
<Icon name="search" size={13} />
|
||||
</span>
|
||||
<input class="input" bind:value={search} placeholder="Name suchen…" style="padding-left:28px;width:180px" />
|
||||
</div>
|
||||
|
||||
<!-- CSV import (hidden file input) -->
|
||||
<label class="btn ghost" style="cursor:pointer">
|
||||
<Icon name="download" size={12} /> CSV importieren
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
bind:this={fileInput}
|
||||
onchange={handleImport}
|
||||
style="display:none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Add form inline -->
|
||||
<form onsubmit={addStudent} style="display:flex;gap:6px">
|
||||
<input class="input" bind:value={newStudentName} placeholder="Name…" required style="width:150px" />
|
||||
<button class="btn" type="submit"><Icon name="plus" size={12} /> Hinzufügen</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Table -->
|
||||
<section class="card" style="overflow:hidden">
|
||||
{#if filtered.length === 0}
|
||||
<div style="padding:32px;text-align:center">
|
||||
<span class="small" style="color:var(--ink-4)">
|
||||
{search ? 'Keine Treffer.' : 'Noch keine Studierenden.'}
|
||||
</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;text-align:right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filtered as student, 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">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="width:22px;height:22px;border-radius:50%;background:var(--paper-2);color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:9px;font-weight:600;flex-shrink:0">
|
||||
{initials(student.name)}
|
||||
</span>
|
||||
{student.name}
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:12px 14px;text-align:right">
|
||||
<button
|
||||
class="btn ghost sm"
|
||||
style="color:var(--accent);border-color:rgba(138,44,31,0.2)"
|
||||
onclick={() => deleteStudent(student.id)}
|
||||
>Entfernen</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user