feat(frontend): merge paper/notebook design overhaul
Complete redesign of the SvelteKit frontend with a paper/notebook aesthetic: design tokens, Google Fonts, 10 new components, responsive student check-in, live seat map, and redesigned admin pages.
This commit is contained in:
151
frontend/src/app.css
Normal file
151
frontend/src/app.css
Normal file
@@ -0,0 +1,151 @@
|
||||
/* Academic / paper-inspired design system for Tutormanager */
|
||||
|
||||
:root {
|
||||
--paper: #f4efe6;
|
||||
--paper-2: #ebe4d6;
|
||||
--paper-3: #ded4c0;
|
||||
--rule: #c9bfa9;
|
||||
--rule-soft: #d9d0bb;
|
||||
--ink: #1f1b16;
|
||||
--ink-2: #3a342b;
|
||||
--ink-3: #6b6356;
|
||||
--ink-4: #968b7a;
|
||||
--accent: #8a2c1f; /* oxblood */
|
||||
--accent-soft: #c66a5b;
|
||||
--highlight: #f1d36a; /* highlighter yellow */
|
||||
--highlight-soft: #f5e3a4;
|
||||
--green: #4a6b3a; /* present */
|
||||
--red: #8a2c1f; /* absent / lock */
|
||||
--amber: #b07d2a; /* open */
|
||||
|
||||
--serif: "Source Serif 4", "Source Serif Pro", "EB Garamond", Georgia, serif;
|
||||
--sans: "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
/* Paper grain — subtle SVG noise overlay */
|
||||
.paper-bg {
|
||||
background-color: var(--paper);
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 15%, rgba(160,140,110,0.05) 0, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, rgba(160,140,110,0.04) 0, transparent 50%),
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.12 0 0 0 0 0.10 0 0 0 0 0.07 0 0 0 0.06 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
}
|
||||
|
||||
/* Ruled lines — like a notebook */
|
||||
.ruled {
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent 27px,
|
||||
var(--rule-soft) 27px,
|
||||
var(--rule-soft) 28px
|
||||
);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body, .ui {
|
||||
font-family: var(--sans);
|
||||
color: var(--ink);
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
}
|
||||
|
||||
.serif { font-family: var(--serif); font-weight: 400; letter-spacing: -0.01em; }
|
||||
.mono { font-family: var(--mono); }
|
||||
|
||||
.h1 { font-family: var(--serif); font-weight: 500; font-size: 44px; line-height: 1.05; letter-spacing: -0.02em; color: var(--ink); }
|
||||
.h2 { font-family: var(--serif); font-weight: 500; font-size: 28px; line-height: 1.15; letter-spacing: -0.01em; color: var(--ink); }
|
||||
.h3 { font-family: var(--serif); font-weight: 500; font-size: 20px; line-height: 1.2; color: var(--ink); }
|
||||
.eyebrow { font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-3); }
|
||||
.body { font-family: var(--sans); font-size: 14px; line-height: 1.5; color: var(--ink-2); }
|
||||
.small { font-family: var(--sans); font-size: 12px; color: var(--ink-3); }
|
||||
.tiny { font-family: var(--sans); font-size: 11px; color: var(--ink-3); }
|
||||
|
||||
/* Underline marker — looks like a hand drawn highlight stroke */
|
||||
.marker {
|
||||
background: linear-gradient(180deg, transparent 60%, var(--highlight-soft) 60%, var(--highlight-soft) 92%, transparent 92%);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* Status pills */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
padding: 3px 9px; border-radius: 999px;
|
||||
border: 1px solid currentColor;
|
||||
background: transparent;
|
||||
}
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
|
||||
.pill.open { color: var(--amber); background: rgba(176,125,42,0.08); }
|
||||
.pill.closed { color: var(--ink-3); }
|
||||
.pill.locked { color: var(--accent); background: rgba(138,44,31,0.06); }
|
||||
.pill.present { color: var(--green); background: rgba(74,107,58,0.08); }
|
||||
.pill.absent { color: var(--accent); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
font-family: var(--sans); font-size: 13px; font-weight: 500;
|
||||
padding: 8px 14px; border-radius: 6px;
|
||||
border: 1px solid var(--ink); background: var(--ink); color: var(--paper);
|
||||
cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.btn.ghost { background: transparent; color: var(--ink); border-color: var(--rule); }
|
||||
.btn.ghost:hover { background: rgba(0,0,0,0.04); }
|
||||
.btn.accent { background: var(--accent); border-color: var(--accent); color: #f7eedc; }
|
||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: #fbf7ee;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
/* Marginalia — handwritten look. Use Caveat as marginal handwriting. */
|
||||
.handwritten {
|
||||
font-family: "Caveat", "Kalam", cursive;
|
||||
color: var(--accent);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Custom scrollbars */
|
||||
.scroll::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
.scroll::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 8px; }
|
||||
.scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* hand-drawn underline svg accent under section titles */
|
||||
.underline-stroke { display: inline-block; margin-top: 2px; }
|
||||
|
||||
/* Stamp — for "PRÄSENT" mark */
|
||||
.stamp {
|
||||
display: inline-block; font-family: var(--mono); font-weight: 700; font-size: 11px;
|
||||
letter-spacing: 0.15em; text-transform: uppercase;
|
||||
color: var(--accent); border: 2px solid var(--accent);
|
||||
padding: 3px 8px; border-radius: 3px;
|
||||
transform: rotate(-4deg);
|
||||
background: rgba(138,44,31,0.04);
|
||||
}
|
||||
|
||||
/* Subtle hover row */
|
||||
.row-hover:hover { background: rgba(0,0,0,0.025); }
|
||||
|
||||
/* Tab bar */
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--rule); }
|
||||
.tab { font-family: var(--sans); font-size: 13px; padding: 10px 14px; color: var(--ink-3); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||
.tab.active { color: var(--ink); border-bottom-color: var(--ink); }
|
||||
|
||||
/* Input */
|
||||
.input {
|
||||
font-family: var(--sans); font-size: 13px;
|
||||
padding: 8px 11px; border: 1px solid var(--rule);
|
||||
background: #fbf7ee; border-radius: 4px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.input:focus { outline: none; border-color: var(--ink); }
|
||||
|
||||
/* Inline divider */
|
||||
.div-h { height: 1px; background: var(--rule); width: 100%; }
|
||||
@@ -4,6 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,500;0,8..60,600;0,8..60,700;1,8..60,400&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&family=Caveat:wght@400;500;600&display=swap">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -125,10 +125,10 @@ export const api = {
|
||||
}
|
||||
},
|
||||
checkin: {
|
||||
getInfo: (code: string) => request<any>(`/api/checkin/${code}`),
|
||||
getStudents: (code: string) => request<any[]>(`/api/checkin/${code}/students`),
|
||||
post: (code: string, student_id: number, seat_id?: string) =>
|
||||
request<any>('/api/checkin', {
|
||||
getInfo: (code: string) => request<any>(`/checkin/${code}`),
|
||||
getStudents: (code: string) => request<any[]>(`/checkin/${code}/students`),
|
||||
post: (code: string, student_id: number, seat_id?: string) =>
|
||||
request<any>('/checkin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code, student_id, seat_id })
|
||||
}),
|
||||
|
||||
24
frontend/src/lib/components/Field.svelte
Normal file
24
frontend/src/lib/components/Field.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
const { label, value = '', mono = false, suffix, readonly = false } = $props<{
|
||||
label: string;
|
||||
value?: string;
|
||||
mono?: boolean;
|
||||
suffix?: string;
|
||||
readonly?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:3px">
|
||||
<span class="tiny" style="color:var(--ink-3)">{label}</span>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<input
|
||||
class="input"
|
||||
style={mono ? 'font-family:var(--mono);font-size:12px' : ''}
|
||||
value={value}
|
||||
readonly={readonly}
|
||||
/>
|
||||
{#if suffix}
|
||||
<span class="tiny" style="color:var(--ink-4)">{suffix}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
52
frontend/src/lib/components/Icon.svelte
Normal file
52
frontend/src/lib/components/Icon.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
const { name, size = 14 } = $props<{
|
||||
name: 'check'|'x'|'lock'|'open'|'copy'|'edit'|'download'|'arrow'|'search'|'plus';
|
||||
size?: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
{#if name === 'check'}
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.5 7.5 L5.5 10.5 L11.5 3.5"/>
|
||||
</svg>
|
||||
{:else if name === 'x'}
|
||||
<svg width={size} height={size} viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<path d="M3 3 L9 9 M9 3 L3 9"/>
|
||||
</svg>
|
||||
{:else if name === 'lock'}
|
||||
<svg width={size} height={size} viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3">
|
||||
<rect x="2.5" y="6" width="8" height="5.5" rx="1"/>
|
||||
<path d="M4.5 6 V4.5 a2 2 0 0 1 4 0 V6"/>
|
||||
</svg>
|
||||
{:else if name === 'open'}
|
||||
<svg width={size} height={size} viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3">
|
||||
<rect x="2.5" y="6" width="8" height="5.5" rx="1"/>
|
||||
<path d="M4.5 6 V4.5 a2 2 0 0 1 4 0"/>
|
||||
</svg>
|
||||
{:else if name === 'copy'}
|
||||
<svg width={size} height={size} viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2">
|
||||
<rect x="2" y="2" width="7" height="7" rx="1"/>
|
||||
<rect x="4.5" y="4.5" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
{:else if name === 'edit'}
|
||||
<svg width={size} height={size} viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round">
|
||||
<path d="M2 11 L2 9 L9 2 L11 4 L4 11 Z"/>
|
||||
</svg>
|
||||
{:else if name === 'download'}
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M7 2 V9 M3.5 6 L7 9 L10.5 6 M2.5 11.5 H11.5"/>
|
||||
</svg>
|
||||
{:else if name === 'arrow'}
|
||||
<svg width={size} height={size} viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6 H9 M6.5 3 L9 6 L6.5 9"/>
|
||||
</svg>
|
||||
{:else if name === 'search'}
|
||||
<svg width={size} height={size} viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3">
|
||||
<circle cx="5.5" cy="5.5" r="3.5"/>
|
||||
<path d="M8 8 L11 11" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{:else if name === 'plus'}
|
||||
<svg width={size} height={size} viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
|
||||
<path d="M6 2 V10 M2 6 H10"/>
|
||||
</svg>
|
||||
{/if}
|
||||
187
frontend/src/lib/components/NoteEditor.svelte
Normal file
187
frontend/src/lib/components/NoteEditor.svelte
Normal 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>
|
||||
169
frontend/src/lib/components/SeatMap.svelte
Normal file
169
frontend/src/lib/components/SeatMap.svelte
Normal file
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
interface SeatDef {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
table: string;
|
||||
}
|
||||
|
||||
interface StudentRef {
|
||||
id: number;
|
||||
name: string;
|
||||
initials: string;
|
||||
}
|
||||
|
||||
const {
|
||||
assignments = {},
|
||||
students = [],
|
||||
selectedStudent = null,
|
||||
onSeatClick,
|
||||
variant = 'tutor',
|
||||
ownSeat = null,
|
||||
scale = 1,
|
||||
} = $props<{
|
||||
assignments?: Record<string, number>;
|
||||
students?: StudentRef[];
|
||||
selectedStudent?: number | null;
|
||||
onSeatClick?: (seat: SeatDef) => void;
|
||||
variant?: 'tutor' | 'student' | 'student-self';
|
||||
ownSeat?: string | null;
|
||||
scale?: number;
|
||||
}>();
|
||||
|
||||
const W = 760;
|
||||
const H = 460;
|
||||
|
||||
const WALLS = { x: 12, y: 12, w: 736, h: 436 };
|
||||
const WINDOW = { x: 12, y: 120, w: 6, h: 220 };
|
||||
const DOOR = { x: 60, y: 448, w: 70, h: 6 };
|
||||
const BEAMER = { x: 372, y: 24, w: 110, h: 8 };
|
||||
const PODIUM = { x: 332, y: 60, w: 190, h: 38 };
|
||||
const TABLES = [
|
||||
{ id: 'T1', x: 90, y: 150, w: 200, h: 70, label: 'T1' },
|
||||
{ id: 'T2', x: 470, y: 150, w: 200, h: 70, label: 'T2' },
|
||||
{ id: 'T3', x: 90, y: 320, w: 200, h: 70, label: 'T3' },
|
||||
{ id: 'T4', x: 470, y: 320, w: 200, h: 70, label: 'T4' },
|
||||
];
|
||||
|
||||
function makeSeats(): SeatDef[] {
|
||||
return TABLES.flatMap((t) => [
|
||||
{ id: `${t.id}-1`, x: t.x + t.w * 0.28, y: t.y - 22, table: t.id },
|
||||
{ id: `${t.id}-2`, x: t.x + t.w * 0.72, y: t.y - 22, table: t.id },
|
||||
{ id: `${t.id}-3`, x: t.x + t.w * 0.28, y: t.y + t.h + 22, table: t.id },
|
||||
{ id: `${t.id}-4`, x: t.x + t.w * 0.72, y: t.y + t.h + 22, table: t.id },
|
||||
{ id: `${t.id}-5`, x: t.x + t.w + 26, y: t.y + t.h / 2, table: t.id },
|
||||
]);
|
||||
}
|
||||
|
||||
const SEATS = makeSeats();
|
||||
|
||||
function studentById(id: number): StudentRef | undefined {
|
||||
return students.find((s: StudentRef) => s.id === id);
|
||||
}
|
||||
|
||||
function seatStyle(seat: SeatDef): { bg: string; border: string; label: string; labelColor: string; shadow: string } {
|
||||
const sid = assignments[seat.id];
|
||||
const student = sid ? studentById(sid) : undefined;
|
||||
const isSelected = selectedStudent != null && sid === selectedStudent;
|
||||
const isOwn = ownSeat === seat.id;
|
||||
|
||||
if (variant === 'tutor') {
|
||||
if (student) {
|
||||
return {
|
||||
bg: isSelected ? 'var(--ink)' : '#fbf7ee',
|
||||
border: isSelected ? 'var(--ink)' : 'var(--ink-2)',
|
||||
label: student.initials,
|
||||
labelColor: isSelected ? '#f7eedc' : 'var(--ink)',
|
||||
shadow: isSelected ? '0 0 0 3px rgba(241,211,106,0.6)' : 'none',
|
||||
};
|
||||
}
|
||||
return { bg: '#f7f1e3', border: 'var(--ink-4)', label: '', labelColor: 'var(--ink-4)', shadow: 'none' };
|
||||
}
|
||||
|
||||
if (variant === 'student') {
|
||||
if (isOwn) {
|
||||
return { bg: 'var(--accent)', border: 'var(--accent)', label: '★', labelColor: '#f7eedc', shadow: 'none' };
|
||||
}
|
||||
if (sid) {
|
||||
return { bg: '#d6cdb5', border: 'var(--ink-4)', label: '', labelColor: '', shadow: 'none' };
|
||||
}
|
||||
return { bg: '#fbf7ee', border: 'var(--ink-2)', label: '', labelColor: '', shadow: 'none' };
|
||||
}
|
||||
|
||||
// student-self (read-only)
|
||||
if (isOwn) {
|
||||
return { bg: 'var(--accent)', border: 'var(--accent)', label: '★', labelColor: '#f7eedc', shadow: 'none' };
|
||||
}
|
||||
if (sid) {
|
||||
return { bg: '#d6cdb5', border: 'var(--ink-4)', label: '', labelColor: '', shadow: 'none' };
|
||||
}
|
||||
return { bg: '#fbf7ee', border: 'var(--ink-3)', label: '', labelColor: '', shadow: 'none' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="position:relative;width:{W * scale}px;height:{H * scale}px;background:#f7f1e3;border:1px solid var(--rule);border-radius:4px;overflow:hidden;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.02),0 1px 0 rgba(0,0,0,0.03)">
|
||||
|
||||
<!-- Inner graph-paper grid -->
|
||||
<div style="position:absolute;inset:0;background-image:linear-gradient(to right,rgba(110,90,60,0.05) 1px,transparent 1px),linear-gradient(to bottom,rgba(110,90,60,0.05) 1px,transparent 1px);background-size:{24*scale}px {24*scale}px"></div>
|
||||
|
||||
<!-- Design-space inner container at 1× coords, CSS-scaled -->
|
||||
<div style="position:absolute;inset:0;transform:scale({scale});transform-origin:top left;width:{W}px;height:{H}px">
|
||||
|
||||
<!-- Walls -->
|
||||
<div style="position:absolute;left:{WALLS.x}px;top:{WALLS.y}px;width:{WALLS.w}px;height:{WALLS.h}px;border:2px solid var(--ink-2);border-radius:2px"></div>
|
||||
|
||||
<!-- Window -->
|
||||
<div style="position:absolute;left:{WINDOW.x - 2}px;top:{WINDOW.y}px;width:{WINDOW.w + 4}px;height:{WINDOW.h}px;background:#dfeaf0;border-top:2px solid var(--ink-2);border-bottom:2px solid var(--ink-2)">
|
||||
<div style="position:absolute;left:1px;top:50%;width:{WINDOW.w + 2}px;height:2px;background:var(--ink-2)"></div>
|
||||
</div>
|
||||
<div style="position:absolute;left:-8px;top:{WINDOW.y + WINDOW.h / 2 - 6}px;font-family:var(--mono);font-size:9px;color:var(--ink-3);transform:rotate(-90deg);transform-origin:left top;letter-spacing:0.1em;text-transform:uppercase">Fenster</div>
|
||||
|
||||
<!-- Door gap + arc -->
|
||||
<div style="position:absolute;left:{DOOR.x}px;top:{DOOR.y - 2}px;width:{DOOR.w}px;height:4px;background:#f7f1e3"></div>
|
||||
<svg style="position:absolute;left:{DOOR.x}px;top:{DOOR.y - 36}px;pointer-events:none" width="{DOOR.w + 10}" height="40">
|
||||
<path d="M 2 38 Q 2 2 {DOOR.w} 2" stroke="var(--ink-3)" stroke-width="1" stroke-dasharray="2 2" fill="none"/>
|
||||
<line x1="2" y1="38" x2="2" y2="2" stroke="var(--ink-2)" stroke-width="1.5"/>
|
||||
</svg>
|
||||
<div style="position:absolute;left:{DOOR.x + 6}px;top:{DOOR.y + 6}px;font-family:var(--mono);font-size:9px;color:var(--ink-3);letter-spacing:0.1em;text-transform:uppercase">Tür</div>
|
||||
|
||||
<!-- Beamer -->
|
||||
<div style="position:absolute;left:{BEAMER.x}px;top:{BEAMER.y}px;width:{BEAMER.w}px;height:{BEAMER.h}px;background:var(--ink-2);border-radius:1px"></div>
|
||||
<div style="position:absolute;left:{BEAMER.x + BEAMER.w + 6}px;top:{BEAMER.y}px;font-family:var(--mono);font-size:9px;color:var(--ink-3);letter-spacing:0.1em;text-transform:uppercase">Beamer</div>
|
||||
|
||||
<!-- Podium -->
|
||||
<div style="position:absolute;left:{PODIUM.x}px;top:{PODIUM.y}px;width:{PODIUM.w}px;height:{PODIUM.h}px;border:1.5px solid var(--ink-2);background:#efe6d2;border-radius:2px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:10px;color:var(--ink-3);letter-spacing:0.15em;text-transform:uppercase">
|
||||
Pult · Tutor:in
|
||||
</div>
|
||||
|
||||
<!-- Tables -->
|
||||
{#each TABLES as t}
|
||||
<div style="position:absolute;left:{t.x}px;top:{t.y}px;width:{t.w}px;height:{t.h}px;background:#e8dec5;border:1.5px solid var(--ink-2);border-radius:3px;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-size:22px;color:rgba(31,27,22,0.35);font-style:italic">
|
||||
{t.label}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Seats -->
|
||||
{#each SEATS as seat}
|
||||
{@const s = seatStyle(seat)}
|
||||
<button
|
||||
style="position:absolute;left:{seat.x - 18}px;top:{seat.y - 18}px;width:36px;height:36px;border-radius:50%;background:{s.bg};border:1.5px solid {s.border};cursor:{onSeatClick && variant !== 'student-self' ? 'pointer' : 'default'};display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-weight:600;font-size:11px;color:{s.labelColor};padding:0;box-shadow:{s.shadow};transition:background 120ms,border-color 120ms"
|
||||
title={assignments[seat.id] ? (studentById(assignments[seat.id])?.name ?? 'besetzt') : 'frei'}
|
||||
disabled={variant === 'student-self'}
|
||||
onclick={() => onSeatClick?.(seat)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Compass -->
|
||||
<div style="position:absolute;right:18px;bottom:14px;font-family:var(--mono);font-size:9px;color:var(--ink-3);letter-spacing:0.15em">
|
||||
<div style="display:flex;flex-direction:column;align-items:center">
|
||||
<span>N</span>
|
||||
<svg width="14" height="22">
|
||||
<path d="M7 2 L7 20 M3 6 L7 2 L11 6" stroke="var(--ink-3)" stroke-width="1" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
24
frontend/src/lib/components/StatCard.svelte
Normal file
24
frontend/src/lib/components/StatCard.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
const { label, value, suffix, hint, accent } = $props<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
suffix?: string;
|
||||
hint?: string;
|
||||
accent?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div class="card" style="padding: 16px 20px">
|
||||
<span class="eyebrow">{label}</span>
|
||||
<div style="display:flex;align-items:baseline;gap:6px;margin-top:8px">
|
||||
<span style="font-family:var(--serif);font-size:32px;font-weight:500;line-height:1;color:{accent || 'var(--ink)'}">
|
||||
{value}
|
||||
</span>
|
||||
{#if suffix}
|
||||
<span class="tiny" style="color:var(--ink-4)">{suffix}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hint}
|
||||
<div class="tiny" style="color:var(--ink-4);margin-top:4px">{hint}</div>
|
||||
{/if}
|
||||
</div>
|
||||
15
frontend/src/lib/components/StatusPill.svelte
Normal file
15
frontend/src/lib/components/StatusPill.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
const { status } = $props<{
|
||||
status: 'open' | 'closed' | 'locked' | 'present' | 'absent';
|
||||
}>();
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
open: 'OFFEN',
|
||||
closed: 'GESCHL.',
|
||||
locked: 'GESPERRT',
|
||||
present: 'ANWESEND',
|
||||
absent: 'FEHLT',
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class="pill {status}"><span class="dot"></span>{labels[status]}</span>
|
||||
21
frontend/src/lib/components/Tally.svelte
Normal file
21
frontend/src/lib/components/Tally.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
const { label, value, total, suffix, accent } = $props<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
total?: number;
|
||||
suffix?: string;
|
||||
accent?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:2px">
|
||||
<span class="eyebrow">{label}</span>
|
||||
<span style="font-family:var(--serif);font-size:22px;font-weight:500;color:{accent || 'var(--ink)'}">
|
||||
{value}
|
||||
{#if total !== undefined}
|
||||
<span class="tiny" style="color:var(--ink-4)"> / {total}</span>
|
||||
{:else if suffix}
|
||||
<span class="tiny" style="color:var(--ink-4)"> {suffix}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
96
frontend/src/lib/components/TutorShell.svelte
Normal file
96
frontend/src/lib/components/TutorShell.svelte
Normal 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>
|
||||
21
frontend/src/lib/components/UnderlineStroke.svelte
Normal file
21
frontend/src/lib/components/UnderlineStroke.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
const { width = 110, color = 'var(--accent)' } = $props<{
|
||||
width?: number;
|
||||
color?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={width}
|
||||
height="8"
|
||||
viewBox="0 0 {width} 8"
|
||||
fill="none"
|
||||
style="display:block;margin-top:2px"
|
||||
>
|
||||
<path
|
||||
d="M 2 5 Q {width * 0.25} 1 {width * 0.5} 4 T {width - 2} 3"
|
||||
stroke={color}
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
@@ -1 +1,4 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
</script>
|
||||
<slot />
|
||||
|
||||
@@ -1,47 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { token } from '$lib/auth';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { token } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
if ($token) {
|
||||
goto('/admin');
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
goto($token ? '/admin' : '/admin/login');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="welcome">
|
||||
<h1>FPTutor Attendance</h1>
|
||||
<p>Efficiently tracking attendance and student observations.</p>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/login" class="btn">Tutor Login</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Students: Please use the link provided by your tutor during the session.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.welcome {
|
||||
max-width: 600px;
|
||||
margin: 100px auto;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
}
|
||||
h1 { font-size: 2.5em; color: #333; margin-bottom: 10px; }
|
||||
p { color: #666; font-size: 1.2em; }
|
||||
.actions { margin-top: 40px; }
|
||||
.btn {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 12px 30px;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.footer { margin-top: 60px; font-size: 0.9em; color: #888; }
|
||||
</style>
|
||||
|
||||
@@ -1,65 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { token, logout } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Snippet } 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('/login');
|
||||
}
|
||||
});
|
||||
const { children }: { children: Snippet } = $props();
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
goto('/login');
|
||||
let course = $state<Course | null>(null);
|
||||
|
||||
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()}
|
||||
{@render children()}
|
||||
{/snippet}
|
||||
</TutorShell>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/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,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 for="course-name" class="tiny" style="color:var(--ink-3)">Kursname</label>
|
||||
<input id="course-name" 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 for="course-semester" class="tiny" style="color:var(--ink-3)">Semester</label>
|
||||
<input id="course-semester" 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>
|
||||
|
||||
234
frontend/src/routes/admin/live/[slotId]/+page.svelte
Normal file
234
frontend/src/routes/admin/live/[slotId]/+page.svelte
Normal file
@@ -0,0 +1,234 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
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;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>
|
||||
72
frontend/src/routes/admin/login/+page.svelte
Normal file
72
frontend/src/routes/admin/login/+page.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { token } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
||||
|
||||
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: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Ungültige Anmeldedaten';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="paper-bg" style="min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 20px">
|
||||
|
||||
<!-- 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,172 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import RoomCanvas from '$lib/RoomCanvas.svelte';
|
||||
import type { Course, Session, Slot, Room, Student, LayoutElement, Note } from '$lib/types';
|
||||
|
||||
let courses = $state<Course[]>([]);
|
||||
let selectedCourseId = $state<number | null>(null);
|
||||
let sessions = $state<Session[]>([]);
|
||||
let selectedSessionId = $state<number | null>(null);
|
||||
let slots = $state<Slot[]>([]);
|
||||
let selectedSlotId = $state<number | null>(null);
|
||||
|
||||
let layout = $state<LayoutElement[]>([]);
|
||||
let notes = $state<Note[]>([]);
|
||||
let students = $state<Student[]>([]);
|
||||
let attendances = $state<any[]>([]);
|
||||
|
||||
onMount(async () => {
|
||||
courses = await api.admin.courses.list();
|
||||
if (courses.length > 0) selectedCourseId = courses[0].id;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedCourseId) {
|
||||
api.admin.sessions.list(selectedCourseId).then(res => {
|
||||
sessions = res;
|
||||
if (sessions.length > 0) selectedSessionId = sessions[0].id;
|
||||
});
|
||||
api.admin.courses.listStudents(selectedCourseId).then(res => students = res);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedSessionId) {
|
||||
const s = sessions.find(s => s.id === selectedSessionId);
|
||||
slots = s?.slots || [];
|
||||
if (slots.length > 0) selectedSlotId = slots[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedSlotId) {
|
||||
const slot = slots.find(s => s.id === selectedSlotId);
|
||||
if (slot?.room_id) {
|
||||
api.admin.rooms.get(slot.room_id).then(r => layout = r.layout);
|
||||
} else {
|
||||
layout = [];
|
||||
}
|
||||
api.admin.slots.getNotes(selectedSlotId).then(res => notes = res);
|
||||
api.admin.sessions.getAttendance(selectedSessionId!).then(res => {
|
||||
attendances = res.attendances.filter((a: any) => a.slot_id === selectedSlotId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let activeSeatId = $state<string | null>(null);
|
||||
let noteContent = $state('');
|
||||
|
||||
function handleSeatClick(el: LayoutElement) {
|
||||
if (el.type !== 'seat') return;
|
||||
activeSeatId = el.id;
|
||||
const studentId = attendances.find(a => a.seat_id === el.id)?.student_id;
|
||||
if (studentId) {
|
||||
const existing = notes.find(n => n.student_id === studentId);
|
||||
noteContent = existing?.content || '';
|
||||
} else {
|
||||
noteContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
if (!selectedSlotId || !activeSeatId) return;
|
||||
const studentId = attendances.find(a => a.seat_id === activeSeatId)?.student_id;
|
||||
if (!studentId) {
|
||||
alert('No student at this seat');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.admin.slots.upsertNote(selectedSlotId, studentId, noteContent);
|
||||
notes = await api.admin.slots.getNotes(selectedSlotId);
|
||||
activeSeatId = null;
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
}
|
||||
|
||||
let studentNames = $derived.by(() => {
|
||||
const map: Record<string, string> = {};
|
||||
attendances.forEach(a => {
|
||||
if (a.seat_id) {
|
||||
const s = students.find(s => s.id === a.student_id);
|
||||
map[a.seat_id] = s?.name || 'Unknown';
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
let occupiedSeatIds = $derived(attendances.map(a => a.seat_id).filter(id => id !== null));
|
||||
</script>
|
||||
|
||||
<h1>Seat Notes</h1>
|
||||
|
||||
<div class="selectors">
|
||||
<select bind:value={selectedCourseId}>
|
||||
{#each courses as course}
|
||||
<option value={course.id}>{course.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select bind:value={selectedSessionId}>
|
||||
{#each sessions as session}
|
||||
<option value={session.id}>Week {session.week_nr}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select bind:value={selectedSlotId}>
|
||||
{#each slots as slot}
|
||||
<option value={slot.id}>{slot.start_time} - {slot.end_time}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="notes-container">
|
||||
<div class="map-view">
|
||||
<RoomCanvas
|
||||
elements={layout}
|
||||
{occupiedSeatIds}
|
||||
selectedId={activeSeatId}
|
||||
{studentNames}
|
||||
onElementClick={handleSeatClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="note-editor">
|
||||
{#if activeSeatId}
|
||||
{@const studentName = studentNames[activeSeatId]}
|
||||
<h3>Note for {studentName || 'Empty Seat'}</h3>
|
||||
{#if studentName}
|
||||
<textarea bind:value={noteContent} placeholder="Enter observations..."></textarea>
|
||||
<button class="primary" onclick={saveNote}>Save Note</button>
|
||||
{:else}
|
||||
<p>Select a seat occupied by a student to leave a note.</p>
|
||||
{/if}
|
||||
<button onclick={() => activeSeatId = null}>Cancel</button>
|
||||
{:else}
|
||||
<p>Click a seat on the map to add or view notes.</p>
|
||||
{/if}
|
||||
|
||||
<div class="existing-notes">
|
||||
<h3>Recent Notes</h3>
|
||||
{#each notes as note}
|
||||
<div class="note-item">
|
||||
<strong>{students.find(s => s.id === note.student_id)?.name}:</strong>
|
||||
<p>{note.content}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.selectors { margin-bottom: 20px; }
|
||||
select { margin-right: 10px; padding: 5px; }
|
||||
.notes-container { display: grid; grid-template-columns: 1fr 300px; gap: 20px; }
|
||||
.note-editor { background: #f8f9fa; padding: 20px; border-radius: 8px; }
|
||||
textarea { width: 100%; height: 100px; margin: 10px 0; }
|
||||
.primary { background: #007bff; color: white; border: none; padding: 8px 16px; border-radius: 4px; }
|
||||
.note-item { font-size: 0.9em; border-bottom: 1px solid #ddd; padding: 5px 0; }
|
||||
.note-item p { margin: 5px 0; }
|
||||
</style>
|
||||
@@ -1,205 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import RoomCanvas from '$lib/RoomCanvas.svelte';
|
||||
import type { Room, LayoutElement } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { Room } from '$lib/types';
|
||||
|
||||
let rooms = $state<Room[]>([]);
|
||||
let selectedRoomId = $state<number | null>(null);
|
||||
let selectedRoom = $state<Room | null>(null);
|
||||
let newRoomName = $state('');
|
||||
let rooms = $state<Room[]>([]);
|
||||
let newRoomName = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
await loadRooms();
|
||||
});
|
||||
onMount(async () => {
|
||||
rooms = await api.admin.rooms.list();
|
||||
});
|
||||
|
||||
async function loadRooms() {
|
||||
rooms = await api.admin.rooms.list();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (selectedRoomId) {
|
||||
api.admin.rooms.get(selectedRoomId).then(r => selectedRoom = r);
|
||||
}
|
||||
});
|
||||
|
||||
async function createRoom() {
|
||||
const defaultLayout: LayoutElement[] = [
|
||||
{ id: 's1', label: '1', x: 2, y: 2, width: 1, height: 1, type: 'seat' }
|
||||
];
|
||||
try {
|
||||
const room = await api.admin.rooms.create(newRoomName, defaultLayout);
|
||||
newRoomName = '';
|
||||
await loadRooms();
|
||||
selectedRoomId = room.id;
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLayout() {
|
||||
if (!selectedRoom) return;
|
||||
try {
|
||||
await api.admin.rooms.updateLayout(selectedRoom.id, selectedRoom.layout);
|
||||
alert('Layout saved');
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
}
|
||||
|
||||
function addElement(type: LayoutElement['type']) {
|
||||
if (!selectedRoom) return;
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
const newEl: LayoutElement = {
|
||||
id,
|
||||
label: type === 'seat' ? (selectedRoom.layout.filter(e => e.type === 'seat').length + 1).toString() : '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: type === 'table' ? 2 : 1,
|
||||
height: 1,
|
||||
type
|
||||
};
|
||||
selectedRoom.layout = [...selectedRoom.layout, newEl];
|
||||
}
|
||||
|
||||
let selectedElementId = $state<string | null>(null);
|
||||
let selectedElement = $derived(selectedRoom?.layout.find(e => e.id === selectedElementId));
|
||||
|
||||
function deleteElement() {
|
||||
if (!selectedRoom || !selectedElementId) return;
|
||||
selectedRoom.layout = selectedRoom.layout.filter(e => e.id !== selectedElementId);
|
||||
selectedElementId = null;
|
||||
}
|
||||
async function createRoom() {
|
||||
if (!newRoomName.trim()) return;
|
||||
try {
|
||||
await api.admin.rooms.create(newRoomName, []);
|
||||
newRoomName = '';
|
||||
rooms = await api.admin.rooms.list();
|
||||
} catch (_) {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Room Layouts</h1>
|
||||
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
|
||||
<div>
|
||||
<span class="eyebrow">Räume</span>
|
||||
<h1 class="h1" style="font-family:var(--serif)">Raumlayout-Editor</h1>
|
||||
</div>
|
||||
|
||||
<div class="management-grid">
|
||||
<div class="rooms-panel">
|
||||
<h2>Rooms</h2>
|
||||
<form onsubmit={createRoom}>
|
||||
<input bind:value={newRoomName} placeholder="Room Name" required />
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
|
||||
<div class="room-list">
|
||||
{#each rooms as room}
|
||||
<div
|
||||
class="room-item"
|
||||
class:selected={selectedRoomId === room.id}
|
||||
onclick={() => selectedRoomId = room.id}
|
||||
>
|
||||
{room.name}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div 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 class="serif" style="font-size:18px;font-weight:500">Räume</div>
|
||||
<form onsubmit={(e) => { e.preventDefault(); createRoom(); }} style="display:flex;gap:8px">
|
||||
<input class="input" bind:value={newRoomName} placeholder="Raumname" style="width:200px" />
|
||||
<button class="btn" type="submit">+ Neuer Raum</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="editor-panel">
|
||||
{#if selectedRoom}
|
||||
<div class="editor-header">
|
||||
<h2>Editing: {selectedRoom.name}</h2>
|
||||
<div class="toolbar">
|
||||
<button onclick={() => addElement('seat')}>Add Seat</button>
|
||||
<button onclick={() => addElement('table')}>Add Table</button>
|
||||
<button onclick={() => addElement('door')}>Add Door</button>
|
||||
<button class="save-btn" onclick={saveLayout}>Save Layout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container">
|
||||
<RoomCanvas
|
||||
bind:elements={selectedRoom.layout}
|
||||
editable={true}
|
||||
selectedId={selectedElementId}
|
||||
onElementClick={(el) => selectedElementId = el.id}
|
||||
/>
|
||||
|
||||
<div class="properties-panel">
|
||||
<h3>Properties</h3>
|
||||
{#if selectedElement}
|
||||
<div class="field">
|
||||
<label>Label</label>
|
||||
<input bind:value={selectedElement.label} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Width</label>
|
||||
<input type="number" step="0.5" bind:value={selectedElement.width} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Height</label>
|
||||
<input type="number" step="0.5" bind:value={selectedElement.height} />
|
||||
</div>
|
||||
<button class="delete-btn" onclick={deleteElement}>Delete Element</button>
|
||||
{:else}
|
||||
<p>Select an element to edit properties.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Select a room to edit its layout.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if rooms.length === 0}
|
||||
<div style="padding:32px;text-align:center">
|
||||
<span class="small" style="color:var(--ink-4)">Noch keine Räume angelegt.</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">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 rooms as room, i}
|
||||
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
||||
<td style="padding:12px 14px">{room.name}</td>
|
||||
<td style="padding:12px 14px;text-align:right">
|
||||
<a href="/admin/rooms/{room.id}" class="btn ghost sm">Bearbeiten</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.management-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.room-item {
|
||||
padding: 10px;
|
||||
border: 1px solid #eee;
|
||||
margin-bottom: 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.room-item.selected {
|
||||
background: #e7f1ff;
|
||||
border-color: #007bff;
|
||||
}
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.toolbar button {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.save-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.canvas-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
.properties-panel {
|
||||
width: 200px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
.delete-btn {
|
||||
margin-top: 10px;
|
||||
color: red;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
111
frontend/src/routes/admin/rooms/[roomId]/+page.svelte
Normal file
111
frontend/src/routes/admin/rooms/[roomId]/+page.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import RoomCanvas from '$lib/RoomCanvas.svelte';
|
||||
import type { Room, LayoutElement } from '$lib/types';
|
||||
|
||||
const roomId = $derived(parseInt(($page.params as Record<string, string>).roomId));
|
||||
|
||||
let room = $state<Room | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
room = await api.admin.rooms.get(roomId);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (roomId) {
|
||||
api.admin.rooms.get(roomId).then((r: Room) => { room = r; });
|
||||
}
|
||||
});
|
||||
|
||||
async function saveLayout() {
|
||||
if (!room) return;
|
||||
try {
|
||||
await api.admin.rooms.updateLayout(room.id, room.layout);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function addElement(type: LayoutElement['type']) {
|
||||
if (!room) return;
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
const newEl: LayoutElement = {
|
||||
id,
|
||||
label: type === 'seat' ? (room.layout.filter((e: LayoutElement) => e.type === 'seat').length + 1).toString() : '',
|
||||
x: 0, y: 0,
|
||||
width: type === 'table' ? 2 : 1,
|
||||
height: 1,
|
||||
type,
|
||||
};
|
||||
room.layout = [...room.layout, newEl];
|
||||
}
|
||||
|
||||
let selectedElementId = $state<string | null>(null);
|
||||
const selectedElement = $derived(room?.layout.find((e: LayoutElement) => e.id === selectedElementId));
|
||||
|
||||
function deleteElement() {
|
||||
if (!room || !selectedElementId) return;
|
||||
room.layout = room.layout.filter((e: LayoutElement) => e.id !== selectedElementId);
|
||||
selectedElementId = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if room}
|
||||
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<span class="eyebrow">Räume</span>
|
||||
<h2 class="h2" style="font-family:var(--serif)">{room.name}</h2>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn ghost" onclick={() => addElement('seat')}>+ Sitz</button>
|
||||
<button class="btn ghost" onclick={() => addElement('table')}>+ Tisch</button>
|
||||
<button class="btn ghost" onclick={() => addElement('door')}>+ Tür</button>
|
||||
<button class="btn" onclick={saveLayout}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:20px">
|
||||
<div style="flex:1">
|
||||
<RoomCanvas
|
||||
bind:elements={room.layout}
|
||||
editable={true}
|
||||
selectedId={selectedElementId}
|
||||
onElementClick={(el) => { selectedElementId = el.id; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card" style="width:240px;padding:16px;display:flex;flex-direction:column;gap:12px">
|
||||
<div class="eyebrow">Auswahl</div>
|
||||
{#if selectedElement}
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div>
|
||||
<div class="tiny" style="color:var(--ink-3)">Bezeichnung</div>
|
||||
<input class="input" bind:value={selectedElement.label} />
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<div>
|
||||
<div class="tiny" style="color:var(--ink-3)">Breite</div>
|
||||
<input class="input" type="number" step="0.5" bind:value={selectedElement.width} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="tiny" style="color:var(--ink-3)">Höhe</div>
|
||||
<input class="input" type="number" step="0.5" bind:value={selectedElement.height} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
style="color:var(--accent);background:none;border:none;cursor:pointer;text-align:left;font-family:var(--sans);font-size:13px;padding:4px 0"
|
||||
onclick={deleteElement}
|
||||
>Löschen ⌫</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="small" style="color:var(--ink-4)">Element auswählen</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div style="padding:28px 36px">
|
||||
<span class="small" style="color:var(--ink-4)">Raum wird geladen…</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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 for="session-week" class="tiny" style="color:var(--ink-3)">Woche #</label>
|
||||
<input id="session-week" class="input" type="number" bind:value={weekNr} min="1" style="width:80px" />
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
<label for="session-date" class="tiny" style="color:var(--ink-3)">Datum</label>
|
||||
<input id="session-date" 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 for="slot-tutor" class="tiny" style="color:var(--ink-3)">Tutor-ID</label>
|
||||
<input id="slot-tutor" class="input" type="number" bind:value={slotTutorId} placeholder="1" required />
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
<label for="slot-room" class="tiny" style="color:var(--ink-3)">Raum (optional)</label>
|
||||
<select id="slot-room" 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 for="slot-start" class="tiny" style="color:var(--ink-3)">Beginn</label>
|
||||
<input id="slot-start" class="input" type="time" bind:value={startTime} />
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
<label for="slot-end" class="tiny" style="color:var(--ink-3)">Ende</label>
|
||||
<input id="slot-end" 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}
|
||||
|
||||
158
frontend/src/routes/admin/students/+page.svelte
Normal file
158
frontend/src/routes/admin/students/+page.svelte
Normal file
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
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;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>
|
||||
@@ -1,82 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { token } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
let error = '';
|
||||
let 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: any) {
|
||||
error = e.message || 'Invalid credentials';
|
||||
} 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>
|
||||
|
||||
<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>
|
||||
@@ -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