feat(frontend): add TutorShell sidebar layout and NoteEditor with auto-save

This commit is contained in:
2026-04-28 15:39:28 +02:00
parent 7da7c1e1d0
commit 7e326153a8
2 changed files with 283 additions and 0 deletions

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import type { Student, Attendance, Note } from '$lib/types';
import { api } from '$lib/api';
const {
slotId,
students = [],
attendances = [],
notes = [],
selectedStudentId = null,
onStudentSelect,
weekNr = 0,
} = $props<{
slotId: number;
students?: Student[];
attendances?: Attendance[];
notes?: Note[];
selectedStudentId?: number | null;
onStudentSelect?: (id: number) => void;
weekNr?: number;
}>();
const TAGS = [
'aktiv beteiligt',
'stille:r Kämpfer:in',
'verstanden ✓',
'nochmal aufgreifen',
'Rückfrage offen',
'elegante Lösung',
];
const presentIds = $derived(new Set(attendances.map((a: Attendance) => a.student_id)));
const present = $derived(students.filter((s: Student) => presentIds.has(s.id)));
const absent = $derived(students.filter((s: Student) => !presentIds.has(s.id)));
const selected = $derived(students.find((s: Student) => s.id === selectedStudentId) ?? null);
const selectedAttendance = $derived(attendances.find((a: Attendance) => a.student_id === selectedStudentId) ?? null);
function initials(name: string): string {
return name.split(' ').map((w: string) => w[0]).slice(0, 2).join('').toUpperCase();
}
function checkinTime(studentId: number): string {
const att = attendances.find((a: Attendance) => a.student_id === studentId);
if (!att) return '—';
return att.checked_in_at.slice(11, 16);
}
function hasNote(studentId: number): boolean {
return notes.some((n: Note) => n.student_id === studentId && n.content.trim());
}
// Note editing state
let noteContent = $state('');
let savedAt = $state('');
let saveTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
if (selectedStudentId == null) {
noteContent = '';
return;
}
const note = notes.find((n: Note) => n.student_id === selectedStudentId);
noteContent = note?.content ?? '';
});
function onNoteInput(e: Event) {
noteContent = (e.target as HTMLTextAreaElement).value;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(saveNote, 800);
}
async function saveNote() {
if (selectedStudentId == null) return;
try {
await api.admin.slots.upsertNote(slotId, selectedStudentId, noteContent);
const now = new Date();
savedAt = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
} catch (_) {
// silent — user sees no feedback on transient failure
}
}
function appendTag(tag: string) {
noteContent = noteContent ? `${noteContent}\n+ ${tag}` : `+ ${tag}`;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(saveNote, 800);
}
</script>
<div class="card" style="display:flex;flex-direction:column;overflow:hidden;height:100%">
<!-- Roster header -->
<div style="padding:14px 16px 10px;border-bottom:1px solid var(--rule)">
<div class="serif" style="font-size:16px;font-weight:500">Studierende</div>
<div class="tiny" style="margin-top:2px">{present.length} anwesend · {absent.length} fehlen</div>
</div>
<!-- Roster list -->
<div class="scroll" style="overflow-y:auto;max-height:220px;padding:6px 0">
{#each present as s}
{@const isSel = s.id === selectedStudentId}
<button
style="width:100%;text-align:left;border:none;background:{isSel ? 'rgba(31,27,22,0.06)' : 'transparent'};padding:7px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;border-left:{isSel ? '3px solid var(--ink)' : '3px solid transparent'}"
class="row-hover"
onclick={() => onStudentSelect?.(s.id)}
>
<span style="width:22px;height:22px;border-radius:50%;background:var(--ink);color:var(--paper);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:9px;font-weight:600;flex-shrink:0">
{initials(s.name)}
</span>
<span style="flex:1;font-size:13px">{s.name}</span>
{#if hasNote(s.id)}
<span style="width:6px;height:6px;border-radius:50%;background:var(--accent);flex-shrink:0"></span>
{/if}
<span class="mono tiny" style="color:var(--ink-4)">{checkinTime(s.id)}</span>
</button>
{/each}
{#each absent as s}
<button
style="width:100%;text-align:left;border:none;background:transparent;padding:7px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;opacity:0.55;border-left:3px solid transparent"
class="row-hover"
onclick={() => onStudentSelect?.(s.id)}
>
<span style="width:22px;height:22px;border-radius:50%;background:transparent;color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:9px;font-weight:600;border:1px dashed var(--ink-4);flex-shrink:0">
{initials(s.name)}
</span>
<span style="flex:1;font-size:13px;text-decoration:line-through;text-decoration-color:var(--ink-4)">{s.name}</span>
<span class="mono tiny" style="color:var(--ink-4)"></span>
</button>
{/each}
</div>
<!-- Note editor -->
{#if selected}
<div style="border-top:1px solid var(--rule);padding:14px 16px;flex:1;display:flex;flex-direction:column;background:#fbf7ee">
<!-- Selected student header -->
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
<span style="width:32px;height:32px;border-radius:50%;background:var(--ink);color:var(--paper);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:11px;font-weight:600;flex-shrink:0">
{initials(selected.name)}
</span>
<div style="flex:1">
<div class="serif" style="font-size:16px;font-weight:500">{selected.name}</div>
<div class="tiny">
Sitzplatz {selectedAttendance?.seat_id ?? '—'} · seit {checkinTime(selected.id)}
</div>
</div>
<span class="stamp">Präsent</span>
</div>
<div class="eyebrow" style="margin-top:6px;margin-bottom:6px">
Notiz · Woche {String(weekNr).padStart(2, '0')}
</div>
<textarea
value={noteContent}
oninput={onNoteInput}
placeholder="Beobachtungen für diese Woche…"
class="ruled"
style="flex:1;min-height:110px;resize:none;font-family:var(--serif);font-size:15px;line-height:28px;padding:0 0 4px 0;background:transparent;border:none;outline:none;color:var(--ink);width:100%"
></textarea>
<!-- Quick tags -->
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px">
{#each TAGS as tag}
<button
class="pill closed"
style="border-color:var(--rule);cursor:pointer;font-family:var(--sans);text-transform:none;font-size:11px;letter-spacing:0"
onclick={() => appendTag(tag)}
>+ {tag}</button>
{/each}
</div>
<!-- Footer -->
<div class="tiny" style="margin-top:10px;display:flex;justify-content:space-between">
<span>{savedAt ? `Auto-gespeichert · ${savedAt}` : 'Noch nicht gespeichert'}</span>
<a href="/admin/students" style="color:var(--ink-3)">Notizen vergangener Wochen ↗</a>
</div>
</div>
{:else}
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:24px">
<span class="small" style="color:var(--ink-4)">Sitz anklicken um Notiz zu schreiben</span>
</div>
{/if}
</div>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import type { Snippet } from 'svelte';
const {
activePath,
courseName = '',
semester = '',
weekday = '',
tutorName = '',
tutorEmail = '',
children,
} = $props<{
activePath: string;
courseName?: string;
semester?: string;
weekday?: string;
tutorName?: string;
tutorEmail?: string;
children: Snippet;
}>();
const navItems = [
{ id: 'dashboard', label: 'Dashboard', href: '/admin' },
{ id: 'live', label: 'Live · Sitzplan', href: '/admin/sessions' },
{ id: 'attendance', label: 'Anwesenheit', href: '/admin/attendance' },
{ id: 'rooms', label: 'Räume', href: '/admin/rooms' },
{ id: 'students', label: 'Studierende', href: '/admin/students' },
];
function isActive(item: { id: string; href: string }): boolean {
if (item.id === 'dashboard') return activePath === '/admin';
if (item.id === 'live') return activePath.startsWith('/admin/live') || activePath.startsWith('/admin/sessions');
return activePath.startsWith(item.href);
}
function initials(name: string): string {
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
}
</script>
<div class="paper-bg" style="width:100%;min-height:100vh;display:grid;grid-template-columns:220px 1fr;overflow:hidden">
<aside style="border-right:1px solid var(--rule);padding:20px 18px;background:rgba(0,0,0,0.015);display:flex;flex-direction:column;gap:18px;min-height:100vh">
<!-- Brand -->
<div>
<div class="serif" style="font-size:22px;font-weight:500;letter-spacing:-0.01em">
Tutor<span style="color:var(--accent)">·</span>manager
</div>
<div class="tiny" style="margin-top:2px">v0.1 · Puchstein</div>
</div>
<!-- Course block -->
{#if courseName}
<div>
<div class="eyebrow" style="margin-bottom:8px">Kurs</div>
<div style="padding:10px 12px;background:#fbf7ee;border:1px solid var(--rule);border-radius:4px">
<div class="serif" style="font-size:14px;font-weight:500">{courseName}</div>
<div class="tiny" style="margin-top:2px">{semester}{weekday ? ` · ${weekday}s` : ''}</div>
</div>
</div>
{/if}
<!-- Navigation -->
<nav style="display:flex;flex-direction:column;gap:1px">
<div class="eyebrow" style="margin-bottom:6px">Navigation</div>
{#each navItems as item}
{@const active = isActive(item)}
<a
href={item.href}
style="text-align:left;text-decoration:none;background:{active ? 'rgba(31,27,22,0.08)' : 'transparent'};padding:8px 10px;border-radius:4px;font-family:var(--sans);font-size:13px;color:{active ? 'var(--ink)' : 'var(--ink-2)'};font-weight:{active ? 500 : 400};display:flex;align-items:center;gap:8px"
>
<span style="width:4px;height:4px;border-radius:50%;flex-shrink:0;background:{active ? 'var(--accent)' : 'var(--ink-4)'}"></span>
{item.label}
</a>
{/each}
</nav>
<div style="flex:1"></div>
<!-- User profile -->
<div style="border-top:1px solid var(--rule);padding-top:14px;display:flex;align-items:center;gap:8px">
<span style="width:28px;height:28px;border-radius:50%;background:var(--ink);color:var(--paper);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:10px;font-weight:600;flex-shrink:0">
{tutorName ? initials(tutorName) : 'T'}
</span>
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:500">{tutorName || 'Tutor:in'}</div>
<div class="tiny" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{tutorEmail}</div>
</div>
</div>
</aside>
<main style="overflow:auto">
{@render children()}
</main>
</div>